diff --git a/API.md b/API.md index 5e7757d..d3d63e1 100644 --- a/API.md +++ b/API.md @@ -95,7 +95,7 @@ GET /api/orphans 返回可回收的完整容器ID数组。DELETE /api/orphans?id - POST /api/instances/{id}/restore {backup_id,password}(原备份密码;旧平台key包可留空) - POST /api/instances/{id}/upgrade {image_id,password}(当前登录密码,加密pre-upgrade备份) -Profile写入要求实例先停止。备份/还原/升级会受控停启;恢复保留原运行状态,升级成功运行新版,失败恢复旧状态。模板2入口先准备插件再启动应用。删除实例仅删除容器与网络并移除索引,保留数据目录。升级失败恢复实例home,不撤销共享workspace或外部系统副作用。 +Profile写入要求实例先停止。备份/还原/升级会受控停启;恢复保留原运行状态,升级成功运行新版,失败恢复旧状态。模板2入口先准备插件再启动应用。删除实例先清理容器与网络,再永久删除该实例的 home、快照及备份,最后移除实例索引;数据清理失败保留 error 实例以便重试。升级失败恢复实例home,不撤销共享workspace或外部系统副作用。 创建恢复:Docker 创建响应丢失时,首次 inspect 404 不代表原请求已结束。实例保留为可见的 error 状态并继续占用名额,用户可重试删除;后台不会仅凭名称删除身份不匹配资源。不确定创建记录不会自动过期:若资源始终不存在,需管理员确认 Docker 原创建请求已终止后再处理保留记录,不能仅凭等待时长判定安全。构建验证容器在创建前持久登记任务身份,启动时即使没有排队构建也会回收并重试失败清理;持续不存在的未知结果仍保留登记以捕获晚到容器。构建终态和镜像索引事务失败会独立重试并记录日志,不阻塞后续构建;数据库关闭停止重试,重启将未完成构建标记失败。 diff --git a/OPERATIONS.md b/OPERATIONS.md index e41a05c..1bb0385 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -1,5 +1,7 @@ # 本机或可信团队试运行运维 +当前仅支持模板 7 的独立实例布局:实例 home 挂载到 /workspace,不再创建或使用 workspaces//files,也不再迁移旧共享挂载布局。历史 files 目录可在确认旧容器已停止并移除挂载后,由管理员在维护窗口删除;面板不会在启动或查询用量时自动递归删除历史数据。旧版本已经删除实例所留下的目录也需核对数据库及容器引用后单独清理。 + 本版本面向本机或受信任团队,不应直接用于公网不可信租户。DSH 与面板使用同源路径代理;Docker socket 权限等价于宿主 root。 ## 启动 @@ -51,7 +53,7 @@ - Profile是例外:当前需先停止实例,再使用“保存并启动”;页面包含Monaco和脱敏配置预览。不要把备份/升级自动停启理解为Profile可在线改写。 - 若实例进入 `error`,不要直接修改数据库状态强制启动。保留原容器、实例目录与 `upgrade_journal` 记录,检查实际容器与 journal 的旧/新容器 ID、阶段、快照路径是否一致。 - 人工恢复前停止相关容器并另做完整备份。中断点不同,不能统一用一次目录覆盖或状态修改处理;当前无自动灾难恢复命令。 -- 删除实例会删除其容器与网络,但保留数据目录。升级快照暂无自动清理;不要删除仍被未完成 journal 引用的快照。 +- 删除实例会删除其容器、网络、实例目录(含 home 和快照)及专属备份,无法撤销;请事先下载需要保留的数据。清理失败保留 error 实例,修复存储问题后重试删除。未完成升级恢复的实例仍禁止删除。 ## Git 分支发布运行镜像构建配置 @@ -114,4 +116,4 @@ bash scripts/check-trial.sh 该脚本执行本地自动化测试、race、静态检查及前后端构建,不启动容器、不安装依赖、不部署。需提前准备 Go/Node/npm 和前端依赖。可通过 `TEST_TMPDIR` 指定已存在的临时目录。面板构建单测已隔离真实磁盘空闲量;生产磁盘检查仍保留。`image/init-profile.test.mjs` 属于发布分支的 runtime 测试,不在 main 的检查脚本中运行。 -`scripts/test-panel.mjs` 必须指向独立测试部署,通过 `TEST_PANEL_URL`、`TEST_USERNAME`、`TEST_PASSWORD` 指定访问参数;`TEST_UPGRADE=1` 包含同镜像替换升级。运行前需由管理员通过 Git 发布构建准备已验证且准入的本地 runtime 镜像;可用 `TEST_FROM_VERSION` / `TEST_TO_VERSION` 选择源和目标版本。脚本不提交构建,只复用这些镜像,会创建及删除测试实例并留下保留策略要求的数据。不要对生产账号运行。`scripts/smoke-image.sh ` 和 `scripts/test-mobile-image.mjs ` 仅测试已有镜像的运行行为,不读取 main 的构建源;仅在隔离测试环境使用。旧构建脚本和构建后创建意图的 live 测试已删除。 +`scripts/test-panel.mjs` 必须指向独立测试部署,通过 `TEST_PANEL_URL`、`TEST_USERNAME`、`TEST_PASSWORD` 指定访问参数;`TEST_UPGRADE=1` 包含同镜像替换升级。运行前需由管理员通过 Git 发布构建准备已验证且准入的本地 runtime 镜像;可用 `TEST_FROM_VERSION` / `TEST_TO_VERSION` 选择源和目标版本。脚本不提交构建,只复用这些镜像,会创建及删除测试实例,删除时一并清理实例数据。不要对生产账号运行。`scripts/smoke-image.sh ` 和 `scripts/test-mobile-image.mjs ` 仅测试已有镜像的运行行为,不读取 main 的构建源;仅在隔离测试环境使用。旧构建脚本和构建后创建意图的 live 测试已删除。 diff --git a/README.md b/README.md index 13322ed..0e0a310 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ DSH 新运行镜像仅由管理员从本仓库 `dockerfile` 分支获取独立 - 内置账号、管理员配额、短JWT与refresh轮换、失败锁定及审计。 - 管理员 Git 发布检查、本地镜像异步构建/验证/清理、使用已验证且准入的本地镜像创建实例与事务名额(每用户最多两个实例)、启动对账。 - 实例启停删除、独立home(模板7挂载为 `/workspace`)、每实例bridge、归属鉴权及路径反代;DSH认证cookie由服务端保管。 -- 文件上传/下载/重命名/删除及目录ZIP;软配额写前检查、写入串行化与周期巡检。上传最大32 MiB且不覆盖已有文件,删除目录须先清空;删除实例保留数据。 +- 文件上传/下载/重命名/删除及目录ZIP;软配额写前检查、写入串行化与周期巡检。上传最大32 MiB且不覆盖已有文件,删除目录须先清空;删除实例会永久删除其 home、快照及备份。 - 密码加密配置备份、资源目录与manifest、外部加密包导入、恢复健康检查及回滚。备份不再限于六个固定文件;不包含会话、工作区及node_modules。 - 备份/恢复/升级自动编排停启;升级保存密码配置备份和home快照,失败尝试回滚。Profile使用本地Monaco及脱敏预览;目前仍需先停止实例,页面“保存并启动”随后验证就绪。 diff --git a/internal/api/admission.go b/internal/api/admission.go index 0b96a00..7036122 100644 --- a/internal/api/admission.go +++ b/internal/api/admission.go @@ -27,11 +27,7 @@ func defaultAdmission(version, recipe, platform string) string { return "existing_only" } func supportedLayout(recipe string) bool { - switch recipe { - case "3", "4", "5", "6", "7": - return true - } - return false + return recipe == imageRecipeRev } func positiveAdmission(state string) bool { return state == "recommended" || state == "compatible" } diff --git a/internal/api/admission_test.go b/internal/api/admission_test.go index 47a1dfe..e6fa0d5 100644 --- a/internal/api/admission_test.go +++ b/internal/api/admission_test.go @@ -126,13 +126,47 @@ func TestAdmissionDowngradeWaitsForCreate(t *testing.T) { } } -func TestAdmissionHistoricalCreateAndBlock(t *testing.T) { +func TestAdmissionRejectsLegacyLayouts(t *testing.T) { + for _, recipe := range []string{"", "1", "2", "3", "4", "5", "6", "8"} { + t.Run(recipe, func(t *testing.T) { + id := "sha256:" + strings.Repeat("a", 64) + a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/images/") { + t.Error("unexpected Docker operation", r.Method, r.URL.Path) + } + writeJSON(w, map[string]any{"Id": id, "Os": "linux", "Architecture": "amd64", "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "1.2.3", "dsh-mgr.recipe": recipe}}}) + }) + seedVerifiedImage(t, a, id, "1.2.3") + for _, query := range []string{"UPDATE image_builds SET recipe_rev=?", "UPDATE image_admissions SET recipe_rev=?"} { + if _, err := a.s.DB.Exec(query, recipe); err != nil { + t.Fatal(err) + } + } + if _, err := a.s.DB.Exec("UPDATE users SET admin=1"); err != nil { + t.Fatal(err) + } + im, err := a.evaluateImage(context.Background(), id) + if err != nil || im.Selectable || im.Admission != "existing_only" { + t.Fatal(im, err) + } + for _, state := range []string{"compatible", "recommended"} { + w := httptest.NewRecorder() + a.imageAdmission(w, runtimeRequest("POST", "/api/images/admission", fmt.Sprintf(`{"image_id":%q,"state":%q,"evidence":"historical acceptance"}`, id, state), sid)) + if w.Code != 400 { + t.Fatal(w.Code, w.Body.String()) + } + } + }) + } +} + +func TestAdmissionCurrentCreateAndBlock(t *testing.T) { id := "sha256:" + strings.Repeat("a", 64) var body map[string]any a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { switch { case strings.Contains(r.URL.Path, "/images/"): - writeJSON(w, map[string]any{"Id": id, "Os": "linux", "Architecture": "amd64", "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "1.2.3", "dsh-mgr.recipe": "4"}}}) + writeJSON(w, map[string]any{"Id": id, "Os": "linux", "Architecture": "amd64", "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "1.2.3", "dsh-mgr.recipe": imageRecipeRev}}}) case strings.HasSuffix(r.URL.Path, "/containers/create"): if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Error(err) @@ -143,33 +177,33 @@ func TestAdmissionHistoricalCreateAndBlock(t *testing.T) { } }) seedVerifiedImage(t, a, id, "1.2.3") - if _, err := a.s.DB.Exec("UPDATE image_builds SET recipe_rev='4'; DELETE FROM image_admissions; UPDATE users SET admin=1"); err != nil { + if _, err := a.s.DB.Exec("DELETE FROM image_admissions; UPDATE users SET admin=1"); err != nil { t.Fatal(err) } im, err := a.evaluateImage(context.Background(), id) if err != nil || im.Status != "existing_only" || im.Selectable { t.Fatal(im, err) } - records := []buildRecord{{ID: "unused", ImageID: id, Version: "1.2.3", RecipeRev: "4", Platform: imagePlatform, Status: "success"}} + records := []buildRecord{{ID: "unused", ImageID: id, Version: "1.2.3", RecipeRev: imageRecipeRev, Platform: imagePlatform, Status: "success"}} if err := a.reconcileBuilds(context.Background(), records); err != nil || records[0].Status != "success" { t.Fatal(records, err) } admit := func(state string) *httptest.ResponseRecorder { w := httptest.NewRecorder() - a.imageAdmission(w, runtimeRequest("POST", "/api/images/admission", fmt.Sprintf(`{"image_id":%q,"state":%q,"evidence":"legacy layout explicitly accepted"}`, id, state), sid)) + a.imageAdmission(w, runtimeRequest("POST", "/api/images/admission", fmt.Sprintf(`{"image_id":%q,"state":%q,"evidence":"current layout explicitly accepted"}`, id, state), sid)) return w } if w := admit("compatible"); w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } w := httptest.NewRecorder() - a.instances(w, runtimeRequest("POST", "/api/instances", fmt.Sprintf(`{"name":"legacy","image_id":%q}`, id), sid)) + a.instances(w, runtimeRequest("POST", "/api/instances", fmt.Sprintf(`{"name":"current","image_id":%q}`, id), sid)) if w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } host := body["HostConfig"].(map[string]any) mounts := host["Mounts"].([]any) - if len(mounts) != 2 || mounts[0].(map[string]any)["Target"] != "/data/dsh-home" || mounts[1].(map[string]any)["Target"] != "/workspace" || !strings.Contains(fmt.Sprint(body["Env"]), "DSH_HOME=/data/dsh-home") { + if len(mounts) != 1 || mounts[0].(map[string]any)["Target"] != "/workspace" || !strings.Contains(fmt.Sprint(body["Env"]), "DSH_HOME=/workspace") { t.Fatal(body) } var instance store.Instance diff --git a/internal/api/files.go b/internal/api/files.go index 152915c..efa9886 100644 --- a/internal/api/files.go +++ b/internal/api/files.go @@ -80,40 +80,21 @@ func safeRel(p string) (string, error) { return c, nil } -func workspacePath(uid string) string { return path.Join("workspaces", uid, "files") } - -func (a *API) openWorkspace(u store.User, instanceIDs ...string) (*os.Root, error) { - instanceID := "" - if len(instanceIDs) > 0 { - instanceID = instanceIDs[0] +func (a *API) openWorkspace(u store.User, instanceID string) (*os.Root, error) { + if instanceID == "" { + return nil, errors.New("instance required") } base, err := os.OpenRoot(a.root) if err != nil { return nil, err } defer base.Close() - parts := []string{"workspaces", path.Join("workspaces", u.ID), workspacePath(u.ID)} - if instanceID != "" { - rel := path.Join("workspaces", u.ID, "instances", instanceID, "home") - if err := rootSafe(base, rel, false); err != nil { - return nil, err - } - return base.OpenRoot(rel) - } - for _, p := range parts { - if err = rootSafe(base, p, true); err != nil { - return nil, err - } - if err = base.Mkdir(p, 0750); err != nil && !errors.Is(err, os.ErrExist) { - return nil, err - } - } - if err = base.Chown(workspacePath(u.ID), 1000, 1000); err != nil { + rel := path.Join("workspaces", u.ID, "instances", instanceID, "home") + if err := rootSafe(base, rel, false); err != nil { return nil, err } - return base.OpenRoot(workspacePath(u.ID)) + return base.OpenRoot(rel) } - func rootSafe(root *os.Root, rel string, finalMayMissing bool) error { if rel == "." { return nil @@ -184,6 +165,10 @@ func (a *API) workspace(w http.ResponseWriter, r *http.Request) { a.readersMu.Unlock() }() } + if r.URL.Path == "/api/ws/usage" { + a.wsUsage(w, r, u) + return + } user := a.userLocks.Acquire(u.ID) root, err := a.openWorkspace(u, instanceID) user.Release() @@ -193,8 +178,6 @@ func (a *API) workspace(w http.ResponseWriter, r *http.Request) { } defer root.Close() switch strings.TrimPrefix(r.URL.Path, "/api/ws/") { - case "usage": - a.wsUsage(w, r, root, u) case "tree": a.wsTree(w, r, root, u) case "mkdir": @@ -219,7 +202,7 @@ func entry(root *os.Root, rel string) (os.FileInfo, error) { return root.Lstat(rel) } -func (a *API) wsUsage(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) { +func (a *API) wsUsage(w http.ResponseWriter, r *http.Request, u store.User) { if r.Method != "GET" { fail(w, 405, "method not allowed") return @@ -537,6 +520,9 @@ func (a *API) userUsage(u store.User) (int64, error) { } defer base.Close() root, e := base.OpenRoot(path.Join("workspaces", u.ID)) + if errors.Is(e, os.ErrNotExist) { + return 0, nil + } if e != nil { return 0, e } diff --git a/internal/api/files_test.go b/internal/api/files_test.go index 5ee386d..483aaac 100644 --- a/internal/api/files_test.go +++ b/internal/api/files_test.go @@ -449,7 +449,10 @@ func TestWorkspaceSafetyAndUpload(t *testing.T) { if err := s.CreateUser(u, "unused"); err != nil { t.Fatal(err) } - root, e := a.openWorkspace(u) + if err := os.MkdirAll(filepath.Join(a.root, "workspaces/user/instances/one/home"), 0750); err != nil { + t.Fatal(err) + } + root, e := a.openWorkspace(u, "one") if e != nil { t.Fatal(e) } @@ -463,7 +466,7 @@ func TestWorkspaceSafetyAndUpload(t *testing.T) { } outside := t.TempDir() os.WriteFile(filepath.Join(outside, "secret"), []byte("secret"), 0600) - if e = os.Symlink(outside, filepath.Join(a.root, "workspaces/user/files/link")); e != nil { + if e = os.Symlink(outside, filepath.Join(a.root, "workspaces/user/instances/one/home/link")); e != nil { t.Fatal(e) } w := httptest.NewRecorder() diff --git a/internal/api/instance_data.go b/internal/api/instance_data.go new file mode 100644 index 0000000..94be0fd --- /dev/null +++ b/internal/api/instance_data.go @@ -0,0 +1,57 @@ +package api + +import ( + "errors" + "os" + "strings" + + "git.misaka.ren/m1saka/dsh-web/internal/store" +) + +// Call only with the instance lock held, after Docker resources are removed. +func (a *API) removeInstanceData(i store.Instance) error { + for _, id := range []string{i.UID, i.ID} { + if id == "" || id == "." || id == ".." || strings.ContainsAny(id, "/\\") { + return errors.New("invalid instance storage path") + } + } + for _, parts := range [][]string{{"workspaces", i.UID, "instances", i.ID}, {"backups", i.UID, i.ID}} { + if err := a.removeDataDirectory(parts); err != nil { + return err + } + } + return nil +} + +// Walk parents without following symlinks; RemoveAll unlinks child symlinks. +func (a *API) removeDataDirectory(parts []string) error { + root, err := os.OpenRoot(a.root) + if err != nil { + return err + } + defer func() { root.Close() }() + for _, part := range parts[:len(parts)-1] { + st, err := root.Lstat(part) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if !st.IsDir() { + return errors.New("unsafe storage path") + } + next, err := root.OpenRoot(part) + if err != nil { + return err + } + actual, err := next.Stat(".") + if err != nil || !os.SameFile(st, actual) { + next.Close() + return errors.New("storage path changed") + } + root.Close() + root = next + } + return root.RemoveAll(parts[len(parts)-1]) +} diff --git a/internal/api/instance_data_test.go b/internal/api/instance_data_test.go new file mode 100644 index 0000000..88daf13 --- /dev/null +++ b/internal/api/instance_data_test.go @@ -0,0 +1,110 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "git.misaka.ren/m1saka/dsh-web/internal/store" +) + +func TestInstanceDeleteDataAndRetry(t *testing.T) { + for _, blocked := range []bool{false, true} { + t.Run(map[bool]string{false: "success", true: "retry"}[blocked], func(t *testing.T) { + a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + w.WriteHeader(204) + } else { + w.WriteHeader(404) + } + }) + i := store.Instance{ID: "one", UID: "owner", ContainerID: "container"} + if err := a.s.AddInstance(i); err != nil { + t.Fatal(err) + } + write := func(rel string) { + p := filepath.Join(a.root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("keep"), 0600); err != nil { + t.Fatal(err) + } + } + write("workspaces/owner/instances/one/home/data") + write("workspaces/owner/instances/one/pre-restore-test/data") + write("workspaces/owner/instances/two/home/data") + write("backups/owner/one/archive.dshbak") + write("backups/owner/two/archive.dshbak") + outside := t.TempDir() + sentinel := filepath.Join(outside, "sentinel") + if err := os.WriteFile(sentinel, []byte("safe"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(a.root, "workspaces/owner/instances/one/home/link")); err != nil { + t.Fatal(err) + } + backups := filepath.Join(a.root, "backups/owner") + if blocked { + if err := os.Rename(backups, backups+"-saved"); err != nil { + t.Fatal(err) + } + if err := os.Symlink(backups+"-saved", backups); err != nil { + t.Fatal(err) + } + } + remove := func() *httptest.ResponseRecorder { + w := httptest.NewRecorder() + a.instance(w, runtimeRequest("DELETE", "/api/instances/one", "", sid)) + return w + } + w := remove() + if blocked { + if w.Code != 500 { + t.Fatal(w.Code, w.Body.String()) + } + current, err := a.s.GetInstance(i.ID) + if err != nil || current.Status != "error" { + t.Fatal(current, err) + } + if err := os.Remove(backups); err != nil { + t.Fatal(err) + } + if err := os.Rename(backups+"-saved", backups); err != nil { + t.Fatal(err) + } + w = remove() + } + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + for _, rel := range []string{"workspaces/owner/instances/one", "backups/owner/one"} { + if _, err := os.Lstat(filepath.Join(a.root, rel)); !os.IsNotExist(err) { + t.Fatal("data retained", rel, err) + } + } + for _, p := range []string{sentinel, filepath.Join(a.root, "workspaces/owner/instances/two/home/data"), filepath.Join(a.root, "backups/owner/two/archive.dshbak")} { + if _, err := os.Stat(p); err != nil { + t.Fatal("unrelated data removed", err) + } + } + }) + } +} + +func TestUsageDoesNotCreateLegacyFiles(t *testing.T) { + a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("unexpected Docker request") }) + w := httptest.NewRecorder() + a.workspace(w, runtimeRequest("GET", "/api/ws/usage", "", sid)) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + if _, err := os.Stat(filepath.Join(a.root, "workspaces/owner/files")); !os.IsNotExist(err) { + t.Fatal("legacy directory created", err) + } + if _, err := a.openWorkspace(store.User{ID: "owner"}, ""); err == nil { + t.Fatal("shared workspace accepted") + } +} diff --git a/internal/api/orphans.go b/internal/api/orphans.go index ece6fc1..81c49f0 100644 --- a/internal/api/orphans.go +++ b/internal/api/orphans.go @@ -62,17 +62,6 @@ func (a *API) checkOrphan(ctx context.Context, id string) error { return errors.New("host root unavailable") } expected := map[string]string{"/workspace": filepath.Join(host, "workspaces", owner, "instances", inst, "home")} - // Accept complete historical layouts, never arbitrary bind sources. - for _, m := range c.Mounts { - if m.Destination == "/data/dsh-home" { - expected = map[string]string{"/workspace": filepath.Join(host, "workspaces", owner, "files"), "/data/dsh-home": filepath.Join(host, "workspaces", owner, "instances", inst, "home")} - } - } - for _, m := range c.Mounts { - if m.Destination == "/shared" { - expected["/shared"] = filepath.Join(host, "workspaces", owner, "files") - } - } for _, m := range c.Mounts { if m.Type == "tmpfs" && m.Destination == "/tmp" { continue diff --git a/internal/api/orphans_test.go b/internal/api/orphans_test.go index 7179b6f..0137466 100644 --- a/internal/api/orphans_test.go +++ b/internal/api/orphans_test.go @@ -10,7 +10,7 @@ import ( ) func TestOrphanReclaimGuards(t *testing.T) { - for _, kind := range []string{"safe", "running", "foreign-mount", "reference", "upgrade", "confirmation", "nonadmin"} { + for _, kind := range []string{"safe", "legacy", "shared", "running", "foreign-mount", "reference", "upgrade", "confirmation", "nonadmin"} { t.Run(kind, func(t *testing.T) { t.Setenv("HOST_DATA_ROOT", "/host/data") id, inst, owner := strings.Repeat("a", 64), strings.Repeat("b", 32), strings.Repeat("c", 32) @@ -28,7 +28,15 @@ func TestOrphanReclaimGuards(t *testing.T) { if kind == "foreign-mount" { source = "/unrelated" } - writeJSON(w, map[string]any{"Id": id, "Name": "/dsh-inst-" + inst, "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "dsh", "dsh-mgr.instance": inst, "dsh-mgr.owner": owner}}, "State": map[string]any{"Status": "exited", "Running": kind == "running"}, "Mounts": []map[string]string{{"Type": "bind", "Source": source, "Destination": "/workspace"}}}) + mounts := []map[string]string{{"Type": "bind", "Source": source, "Destination": "/workspace"}} + if kind == "legacy" { + mounts[0]["Destination"] = "/data/dsh-home" + mounts = append(mounts, map[string]string{"Type": "bind", "Source": "/host/data/workspaces/" + owner + "/files", "Destination": "/workspace"}) + } + if kind == "shared" { + mounts = append(mounts, map[string]string{"Type": "bind", "Source": "/host/data/workspaces/" + owner + "/files", "Destination": "/shared"}) + } + writeJSON(w, map[string]any{"Id": id, "Name": "/dsh-inst-" + inst, "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "dsh", "dsh-mgr.instance": inst, "dsh-mgr.owner": owner}}, "State": map[string]any{"Status": "exited", "Running": kind == "running"}, "Mounts": mounts}) }) if err := a.s.CreateUser(store.User{ID: owner, Username: "orphan-owner"}, "unused"); err != nil { t.Fatal(err) diff --git a/internal/api/runtime.go b/internal/api/runtime.go index 829c920..3cb5b21 100644 --- a/internal/api/runtime.go +++ b/internal/api/runtime.go @@ -167,6 +167,10 @@ func (a *API) createRuntime(ctx context.Context, u store.User, i store.Instance, return } } + if err := a.removeInstanceData(i); err != nil { + a.s.SetInstance(i.ID, "error") + return + } a.s.DeleteInstance(i.ID) _, _ = a.s.DB.Exec("DELETE FROM runtime_networks WHERE instance_id=?", i.ID) _, _ = a.s.DB.Exec("DELETE FROM runtime_creates WHERE instance_id=?", i.ID) @@ -191,7 +195,7 @@ func (a *API) createRuntime(ctx context.Context, u store.User, i store.Instance, return i, errors.New("storage unavailable") } suffix := filepath.Join("workspaces", u.ID) - for _, relative := range []string{filepath.Join(suffix, "files"), filepath.Join(suffix, "instances", i.ID, "home")} { + for _, relative := range []string{filepath.Join(suffix, "instances", i.ID, "home")} { dir := filepath.Join(root, relative) if err := os.MkdirAll(dir, 0750); err != nil { return i, errors.New("storage unavailable") @@ -258,14 +262,6 @@ func (a *API) createRuntime(ctx context.Context, u store.User, i store.Instance, }, }, } - if policy.RecipeRev != "7" { - sharedSource, err := toHostPath(root, hostRoot, filepath.Join(root, suffix, "files")) - if err != nil { - return i, err - } - body["Env"] = []string{"DSH_HOME=/data/dsh-home", "HOME=/data/dsh-home", "XDG_DATA_HOME=/data/dsh-home/.local/share", "XDG_CONFIG_HOME=/data/dsh-home/.config"} - body["HostConfig"].(map[string]any)["Mounts"] = []map[string]any{{"Type": "bind", "Source": homeSource, "Target": "/data/dsh-home"}, {"Type": "bind", "Source": sharedSource, "Target": "/workspace"}} - } var created struct { ID string `json:"Id"` } @@ -415,12 +411,17 @@ func (a *API) instance(w http.ResponseWriter, r *http.Request) { fail(w, 502, "network deletion failed") return } + if err := a.removeInstanceData(i); err != nil { + a.s.SetInstance(i.ID, "error") + fail(w, 500, "instance data deletion failed; retry deletion") + return + } if _, err := a.s.DB.ExecContext(ctx, "DELETE FROM instances WHERE id=?", i.ID); err != nil { fail(w, 500, "database error") return } _, _ = a.s.DB.Exec("DELETE FROM runtime_networks WHERE instance_id=?", i.ID) - a.s.AddAudit(u.ID, "instance.delete", i.ID, "data retained") + a.s.AddAudit(u.ID, "instance.delete", i.ID, "instance data deleted") // The instance row is gone and its resources were just cleaned, so // any pending flags cannot resolve later: drop the row unconditionally. _, _ = a.s.DB.Exec("DELETE FROM runtime_creates WHERE instance_id=?", i.ID) diff --git a/internal/api/runtime_test.go b/internal/api/runtime_test.go index d786ae4..bf6685e 100644 --- a/internal/api/runtime_test.go +++ b/internal/api/runtime_test.go @@ -278,8 +278,8 @@ func TestRuntimeLifecycleAndOwnership(t *testing.T) { if w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } - if _, err := os.Stat(dir); err != nil { - t.Fatal("data removed", err) + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatal("instance data retained", err) } if _, err := a.s.GetInstance(i.ID); err == nil { t.Fatal("row retained") diff --git a/internal/api/upgrade.go b/internal/api/upgrade.go index f2bb29e..c86e1b3 100644 --- a/internal/api/upgrade.go +++ b/internal/api/upgrade.go @@ -20,7 +20,7 @@ import ( const upgradeSnapshotLimit int64 = 1 << 30 -func upgradeWorkspace(config, host map[string]json.RawMessage, recipe, home, shared string) error { +func upgradeWorkspace(config, host map[string]json.RawMessage, recipe, home string) error { var mounts []map[string]json.RawMessage if err := json.Unmarshal(host["Mounts"], &mounts); err != nil { return errors.New("unsupported runtime mounts") @@ -30,53 +30,11 @@ func upgradeWorkspace(config, host map[string]json.RawMessage, recipe, home, sha _ = json.Unmarshal(m[key], &value) return value } - homeIndex, sharedIndex := -1, -1 - for n, m := range mounts { - target, source := get(m, "Target"), get(m, "Source") - if target != "/workspace" && target != "/data/dsh-home" && target != "/shared" { - continue - } - if get(m, "Type") != "bind" { - return errors.New("unsupported runtime mount type") - } - switch { - case source == home && (target == "/workspace" || target == "/data/dsh-home") && homeIndex == -1: - homeIndex = n - case source == shared && (target == "/workspace" || target == "/shared") && sharedIndex == -1: - sharedIndex = n - default: - return errors.New("ambiguous runtime mounts") - } - } - if homeIndex == -1 { - return errors.New("instance home mount required") - } if !supportedLayout(recipe) { - return errors.New("unsupported template layout") + return errors.New("unsupported template layout; template 7 required") } - if recipe != "7" { - if get(mounts[homeIndex], "Target") == "/workspace" { - return errors.New("workspace home cannot be downgraded to legacy shared layout") - } - if sharedIndex == -1 || get(mounts[sharedIndex], "Target") != "/workspace" { - return errors.New("legacy shared workspace mount required") - } - var env []string - if raw := config["Env"]; len(raw) != 0 { - if err := json.Unmarshal(raw, &env); err != nil { - return err - } - } - kept := []string{} - for _, entry := range env { - key, _, _ := strings.Cut(entry, "=") - if key != "DSH_HOME" && key != "HOME" && key != "XDG_DATA_HOME" && key != "XDG_CONFIG_HOME" { - kept = append(kept, entry) - } - } - kept = append(kept, "DSH_HOME=/data/dsh-home", "HOME=/data/dsh-home", "XDG_DATA_HOME=/data/dsh-home/.local/share", "XDG_CONFIG_HOME=/data/dsh-home/.config") - config["Env"], _ = json.Marshal(kept) - return nil + if len(mounts) != 1 || get(mounts[0], "Type") != "bind" || get(mounts[0], "Source") != home || get(mounts[0], "Target") != "/workspace" { + return errors.New("independent instance home at /workspace required") } for _, key := range []string{"Binds", "VolumesFrom"} { var values []string @@ -102,11 +60,6 @@ func upgradeWorkspace(config, host map[string]json.RawMessage, recipe, home, sha } } kept = append(kept, "DSH_HOME=/workspace", "HOME=/workspace", "XDG_DATA_HOME=/workspace/.local/share", "XDG_CONFIG_HOME=/workspace/.config") - mounts[homeIndex]["Target"], _ = json.Marshal("/workspace") - if sharedIndex != -1 { - mounts = append(mounts[:sharedIndex], mounts[sharedIndex+1:]...) - } - host["Mounts"], _ = json.Marshal(mounts) config["Env"], _ = json.Marshal(kept) return nil } @@ -373,12 +326,11 @@ func (a *API) upgrade(w http.ResponseWriter, r *http.Request, i store.Instance) hostRoot = a.root } homeSource, homeErr := toHostPath(a.root, hostRoot, filepath.Join(root.Name(), "home")) - sharedSource, sharedErr := toHostPath(a.root, hostRoot, filepath.Join(a.root, "workspaces", i.UID, "files")) - if homeErr != nil || sharedErr != nil { + if homeErr != nil { fail(w, 500, "host storage unavailable") return } - if err = upgradeWorkspace(old.Config, old.HostConfig, image.Config.Labels["dsh-mgr.recipe"], homeSource, sharedSource); err != nil { + if err = upgradeWorkspace(old.Config, old.HostConfig, image.Config.Labels["dsh-mgr.recipe"], homeSource); err != nil { fail(w, 409, "incompatible runtime workspace layout") return } diff --git a/internal/api/upgrade_test.go b/internal/api/upgrade_test.go index 2cb472a..3495d95 100644 --- a/internal/api/upgrade_test.go +++ b/internal/api/upgrade_test.go @@ -23,10 +23,10 @@ func TestUpgradeWorkspaceCompatibility(t *testing.T) { name, recipe, target string shared, reject bool }{ - {"legacy-to-seven", "7", "/data/dsh-home", true, false}, + {"legacy-to-seven", "7", "/data/dsh-home", true, true}, {"single-to-seven", "7", "/workspace", false, false}, - {"migrated-to-seven", "7", "/workspace", true, false}, - {"legacy-to-six", "6", "/data/dsh-home", true, false}, + {"migrated-to-seven", "7", "/workspace", true, true}, + {"legacy-to-six", "6", "/data/dsh-home", true, true}, {"legacy-to-unknown", "", "/data/dsh-home", true, true}, {"single-to-six", "6", "/workspace", false, true}, {"single-to-unknown", "", "/workspace", false, true}, @@ -44,16 +44,10 @@ func TestUpgradeWorkspaceCompatibility(t *testing.T) { host := map[string]json.RawMessage{"Mounts": raw} config := map[string]json.RawMessage{"Env": json.RawMessage(`["CUSTOM=yes","DSH_HOME=/data/dsh-home","HOME=/data/dsh-home","XDG_DATA_HOME=/old/data","XDG_CONFIG_HOME=/old/config"]`)} before := string(config["Env"]) - err := upgradeWorkspace(config, host, tc.recipe, "/instance/home", "/user/files") + err := upgradeWorkspace(config, host, tc.recipe, "/instance/home") if (err != nil) != tc.reject { t.Fatalf("error=%v", err) } - if !tc.reject && tc.recipe != "7" { - if !strings.Contains(string(config["Env"]), "XDG_DATA_HOME=/data/dsh-home/.local/share") || string(host["Mounts"]) != string(raw) { - t.Fatal(config, host) - } - return - } if tc.reject { if string(host["Mounts"]) != string(raw) || string(config["Env"]) != before { t.Fatal("legacy or rejected config changed") @@ -71,7 +65,7 @@ func TestUpgradeWorkspaceCompatibility(t *testing.T) { t.Fatal(got) } first, _ := json.Marshal([]any{config, host}) - if err := upgradeWorkspace(config, host, tc.recipe, "/instance/home", "/user/files"); err != nil { + if err := upgradeWorkspace(config, host, tc.recipe, "/instance/home"); err != nil { t.Fatal(err) } second, _ := json.Marshal([]any{config, host}) @@ -90,9 +84,9 @@ func TestUpgradeWorkspaceRejectsAmbiguousMounts(t *testing.T) { `{"Type":"bind","Source":"/instance/home","Target":"/workspace"}`, } { config := map[string]json.RawMessage{"Env": json.RawMessage(`["CUSTOM=yes"]`)} - host := map[string]json.RawMessage{"Mounts": json.RawMessage(`[{"Type":"bind","Source":"/instance/home","Target":"/data/dsh-home"},` + extra + `]`)} + host := map[string]json.RawMessage{"Mounts": json.RawMessage(`[{"Type":"bind","Source":"/instance/home","Target":"/workspace"},` + extra + `]`)} before, _ := json.Marshal([]any{config, host}) - if err := upgradeWorkspace(config, host, "7", "/instance/home", "/user/files"); err == nil { + if err := upgradeWorkspace(config, host, "7", "/instance/home"); err == nil { t.Fatal("accepted ambiguous mounts", extra) } after, _ := json.Marshal([]any{config, host}) @@ -106,7 +100,7 @@ func TestUpgradeLifecycle(t *testing.T) { if os.Geteuid() != 0 { t.Skip("requires chown") } - for _, mode := range []string{"success", "start-failure", "readiness-failure", "cleanup-failure", "running", "running-failure", "restart-failure", "delete-failure", "invalid-image", "unverified-image", "failed-build", "version-mismatch", "image-id-mismatch", "db-failure"} { + for _, mode := range []string{"success", "legacy-layout", "shared-layout", "start-failure", "readiness-failure", "cleanup-failure", "running", "running-failure", "restart-failure", "delete-failure", "invalid-image", "unverified-image", "failed-build", "version-mismatch", "image-id-mismatch", "db-failure"} { t.Run(mode, func(t *testing.T) { image := "sha256:" + strings.Repeat("b", 64) var a *API @@ -149,7 +143,15 @@ func TestUpgradeLifecycle(t *testing.T) { if running { state = "running" } - writeJSON(w, map[string]any{"Config": map[string]any{"Hostname": "old", "Cmd": []string{"obsolete-flag"}, "Image": "old-image", "Env": []string{"CUSTOM=yes"}, "User": "1000:1000"}, "HostConfig": map[string]any{"NetworkMode": "dsh-inst-one", "Memory": 9007199254740993, "Mounts": []map[string]string{{"Type": "bind", "Source": home, "Target": "/data/dsh-home"}, {"Type": "bind", "Source": shared, "Target": "/workspace"}}}, "State": map[string]any{"Status": state, "Running": running && !stopped}, "NetworkSettings": map[string]any{"Networks": map[string]any{"dsh-inst-one": map[string]any{"IPAddress": "172.18.0.3", "Aliases": []string{"old"}}}}}) + mounts := []map[string]string{{"Type": "bind", "Source": home, "Target": "/workspace"}} + if mode == "legacy-layout" { + mounts[0]["Target"] = "/data/dsh-home" + mounts = append(mounts, map[string]string{"Type": "bind", "Source": shared, "Target": "/workspace"}) + } + if mode == "shared-layout" { + mounts = append(mounts, map[string]string{"Type": "bind", "Source": shared, "Target": "/shared"}) + } + writeJSON(w, map[string]any{"Config": map[string]any{"Hostname": "old", "Cmd": []string{"obsolete-flag"}, "Image": "old-image", "Env": []string{"CUSTOM=yes"}, "User": "1000:1000"}, "HostConfig": map[string]any{"NetworkMode": "dsh-inst-one", "Memory": 9007199254740993, "Mounts": mounts}, "State": map[string]any{"Status": state, "Running": running && !stopped}, "NetworkSettings": map[string]any{"Networks": map[string]any{"dsh-inst-one": map[string]any{"IPAddress": "172.18.0.3", "Aliases": []string{"old"}}}}}) case strings.HasPrefix(p, "/images/"): role := "runtime" if mode == "invalid-image" { @@ -273,8 +275,11 @@ func TestUpgradeLifecycle(t *testing.T) { if err != nil { t.Fatal(err) } - if mode == "invalid-image" || mode == "unverified-image" || mode == "failed-build" || mode == "version-mismatch" || mode == "image-id-mismatch" { + if mode == "legacy-layout" || mode == "shared-layout" || mode == "invalid-image" || mode == "unverified-image" || mode == "failed-build" || mode == "version-mismatch" || mode == "image-id-mismatch" { want := 400 + if mode == "legacy-layout" || mode == "shared-layout" { + want = 409 + } if w.Code != want { t.Fatalf("%d %s", w.Code, w.Body.String()) } diff --git a/web/src/Instances.tsx b/web/src/Instances.tsx index dd5b19f..7944e25 100644 --- a/web/src/Instances.tsx +++ b/web/src/Instances.tsx @@ -18,7 +18,7 @@ export function Instances() { const confirm = useConfirm(); async function operate(i: Instance, operation: string) { if (operation === 'delete') { - if (!await confirm.ask({title: '删除实例', danger: true, confirmLabel: '确认删除', body: <>确认删除实例「{i.name}」?此操作不可撤销,实例数据会保留在数据目录中。})) return; + if (!await confirm.ask({title: '删除实例', danger: true, confirmLabel: '永久删除', body: <>确认删除实例「{i.name}」?此操作不可撤销,将永久删除该实例的文件、配置、快照及备份。请先下载需要保留的数据。})) return; } else if (operation !== 'start') { if (!await confirm.ask({title: '停止或重启实例', confirmLabel: '确认执行', body: <>确认停止或重启「{i.name}」?当前连接将中断。})) return; } @@ -26,6 +26,7 @@ export function Instances() { await api.request(instancePath(i.id) + (operation === 'delete' ? '' : '/' + operation), operation === 'delete' ? 'DELETE' : 'POST'); if (selected?.id === i.id && operation === 'delete') setSelected(undefined); await instances.refresh(); + if (operation === 'delete') await usage.refresh(); }, '服务器已完成操作'); } const stats = [