fix: delete instance data and remove legacy shared workspace support
This commit is contained in:
@@ -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}/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备份)
|
||||||
|
|
||||||
Profile写入要求实例先停止。备份/还原/升级会受控停启;恢复保留原运行状态,升级成功运行新版,失败恢复旧状态。模板2入口先准备插件再启动应用。删除实例仅删除容器与网络并移除索引,保留数据目录。升级失败恢复实例home,不撤销共享workspace或外部系统副作用。
|
Profile写入要求实例先停止。备份/还原/升级会受控停启;恢复保留原运行状态,升级成功运行新版,失败恢复旧状态。模板2入口先准备插件再启动应用。删除实例先清理容器与网络,再永久删除该实例的 home、快照及备份,最后移除实例索引;数据清理失败保留 error 实例以便重试。升级失败恢复实例home,不撤销共享workspace或外部系统副作用。
|
||||||
|
|
||||||
创建恢复:Docker 创建响应丢失时,首次 inspect 404 不代表原请求已结束。实例保留为可见的 error 状态并继续占用名额,用户可重试删除;后台不会仅凭名称删除身份不匹配资源。不确定创建记录不会自动过期:若资源始终不存在,需管理员确认 Docker 原创建请求已终止后再处理保留记录,不能仅凭等待时长判定安全。构建验证容器在创建前持久登记任务身份,启动时即使没有排队构建也会回收并重试失败清理;持续不存在的未知结果仍保留登记以捕获晚到容器。构建终态和镜像索引事务失败会独立重试并记录日志,不阻塞后续构建;数据库关闭停止重试,重启将未完成构建标记失败。
|
创建恢复:Docker 创建响应丢失时,首次 inspect 404 不代表原请求已结束。实例保留为可见的 error 状态并继续占用名额,用户可重试删除;后台不会仅凭名称删除身份不匹配资源。不确定创建记录不会自动过期:若资源始终不存在,需管理员确认 Docker 原创建请求已终止后再处理保留记录,不能仅凭等待时长判定安全。构建验证容器在创建前持久登记任务身份,启动时即使没有排队构建也会回收并重试失败清理;持续不存在的未知结果仍保留登记以捕获晚到容器。构建终态和镜像索引事务失败会独立重试并记录日志,不阻塞后续构建;数据库关闭停止重试,重启将未完成构建标记失败。
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -1,5 +1,7 @@
|
|||||||
# 本机或可信团队试运行运维
|
# 本机或可信团队试运行运维
|
||||||
|
|
||||||
|
当前仅支持模板 7 的独立实例布局:实例 home 挂载到 /workspace,不再创建或使用 workspaces/<uid>/files,也不再迁移旧共享挂载布局。历史 files 目录可在确认旧容器已停止并移除挂载后,由管理员在维护窗口删除;面板不会在启动或查询用量时自动递归删除历史数据。旧版本已经删除实例所留下的目录也需核对数据库及容器引用后单独清理。
|
||||||
|
|
||||||
本版本面向本机或受信任团队,不应直接用于公网不可信租户。DSH 与面板使用同源路径代理;Docker socket 权限等价于宿主 root。
|
本版本面向本机或受信任团队,不应直接用于公网不可信租户。DSH 与面板使用同源路径代理;Docker socket 权限等价于宿主 root。
|
||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
@@ -51,7 +53,7 @@
|
|||||||
- Profile是例外:当前需先停止实例,再使用“保存并启动”;页面包含Monaco和脱敏配置预览。不要把备份/升级自动停启理解为Profile可在线改写。
|
- Profile是例外:当前需先停止实例,再使用“保存并启动”;页面包含Monaco和脱敏配置预览。不要把备份/升级自动停启理解为Profile可在线改写。
|
||||||
- 若实例进入 `error`,不要直接修改数据库状态强制启动。保留原容器、实例目录与 `upgrade_journal` 记录,检查实际容器与 journal 的旧/新容器 ID、阶段、快照路径是否一致。
|
- 若实例进入 `error`,不要直接修改数据库状态强制启动。保留原容器、实例目录与 `upgrade_journal` 记录,检查实际容器与 journal 的旧/新容器 ID、阶段、快照路径是否一致。
|
||||||
- 人工恢复前停止相关容器并另做完整备份。中断点不同,不能统一用一次目录覆盖或状态修改处理;当前无自动灾难恢复命令。
|
- 人工恢复前停止相关容器并另做完整备份。中断点不同,不能统一用一次目录覆盖或状态修改处理;当前无自动灾难恢复命令。
|
||||||
- 删除实例会删除其容器与网络,但保留数据目录。升级快照暂无自动清理;不要删除仍被未完成 journal 引用的快照。
|
- 删除实例会删除其容器、网络、实例目录(含 home 和快照)及专属备份,无法撤销;请事先下载需要保留的数据。清理失败保留 error 实例,修复存储问题后重试删除。未完成升级恢复的实例仍禁止删除。
|
||||||
|
|
||||||
## Git 分支发布运行镜像构建配置
|
## Git 分支发布运行镜像构建配置
|
||||||
|
|
||||||
@@ -114,4 +116,4 @@ bash scripts/check-trial.sh
|
|||||||
|
|
||||||
该脚本执行本地自动化测试、race、静态检查及前后端构建,不启动容器、不安装依赖、不部署。需提前准备 Go/Node/npm 和前端依赖。可通过 `TEST_TMPDIR` 指定已存在的临时目录。面板构建单测已隔离真实磁盘空闲量;生产磁盘检查仍保留。`image/init-profile.test.mjs` 属于发布分支的 runtime 测试,不在 main 的检查脚本中运行。
|
该脚本执行本地自动化测试、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 <image-id>` 和 `scripts/test-mobile-image.mjs <image-id>` 仅测试已有镜像的运行行为,不读取 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 <image-id>` 和 `scripts/test-mobile-image.mjs <image-id>` 仅测试已有镜像的运行行为,不读取 main 的构建源;仅在隔离测试环境使用。旧构建脚本和构建后创建意图的 live 测试已删除。
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ DSH 新运行镜像仅由管理员从本仓库 `dockerfile` 分支获取独立
|
|||||||
- 内置账号、管理员配额、短JWT与refresh轮换、失败锁定及审计。
|
- 内置账号、管理员配额、短JWT与refresh轮换、失败锁定及审计。
|
||||||
- 管理员 Git 发布检查、本地镜像异步构建/验证/清理、使用已验证且准入的本地镜像创建实例与事务名额(每用户最多两个实例)、启动对账。
|
- 管理员 Git 发布检查、本地镜像异步构建/验证/清理、使用已验证且准入的本地镜像创建实例与事务名额(每用户最多两个实例)、启动对账。
|
||||||
- 实例启停删除、独立home(模板7挂载为 `/workspace`)、每实例bridge、归属鉴权及路径反代;DSH认证cookie由服务端保管。
|
- 实例启停删除、独立home(模板7挂载为 `/workspace`)、每实例bridge、归属鉴权及路径反代;DSH认证cookie由服务端保管。
|
||||||
- 文件上传/下载/重命名/删除及目录ZIP;软配额写前检查、写入串行化与周期巡检。上传最大32 MiB且不覆盖已有文件,删除目录须先清空;删除实例保留数据。
|
- 文件上传/下载/重命名/删除及目录ZIP;软配额写前检查、写入串行化与周期巡检。上传最大32 MiB且不覆盖已有文件,删除目录须先清空;删除实例会永久删除其 home、快照及备份。
|
||||||
- 密码加密配置备份、资源目录与manifest、外部加密包导入、恢复健康检查及回滚。备份不再限于六个固定文件;不包含会话、工作区及node_modules。
|
- 密码加密配置备份、资源目录与manifest、外部加密包导入、恢复健康检查及回滚。备份不再限于六个固定文件;不包含会话、工作区及node_modules。
|
||||||
- 备份/恢复/升级自动编排停启;升级保存密码配置备份和home快照,失败尝试回滚。Profile使用本地Monaco及脱敏预览;目前仍需先停止实例,页面“保存并启动”随后验证就绪。
|
- 备份/恢复/升级自动编排停启;升级保存密码配置备份和home快照,失败尝试回滚。Profile使用本地Monaco及脱敏预览;目前仍需先停止实例,页面“保存并启动”随后验证就绪。
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,7 @@ func defaultAdmission(version, recipe, platform string) string {
|
|||||||
return "existing_only"
|
return "existing_only"
|
||||||
}
|
}
|
||||||
func supportedLayout(recipe string) bool {
|
func supportedLayout(recipe string) bool {
|
||||||
switch recipe {
|
return recipe == imageRecipeRev
|
||||||
case "3", "4", "5", "6", "7":
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
func positiveAdmission(state string) bool { return state == "recommended" || state == "compatible" }
|
func positiveAdmission(state string) bool { return state == "recommended" || state == "compatible" }
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
id := "sha256:" + strings.Repeat("a", 64)
|
||||||
var body map[string]any
|
var body map[string]any
|
||||||
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(r.URL.Path, "/images/"):
|
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"):
|
case strings.HasSuffix(r.URL.Path, "/containers/create"):
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
@@ -143,33 +177,33 @@ func TestAdmissionHistoricalCreateAndBlock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
seedVerifiedImage(t, a, id, "1.2.3")
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
im, err := a.evaluateImage(context.Background(), id)
|
im, err := a.evaluateImage(context.Background(), id)
|
||||||
if err != nil || im.Status != "existing_only" || im.Selectable {
|
if err != nil || im.Status != "existing_only" || im.Selectable {
|
||||||
t.Fatal(im, err)
|
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" {
|
if err := a.reconcileBuilds(context.Background(), records); err != nil || records[0].Status != "success" {
|
||||||
t.Fatal(records, err)
|
t.Fatal(records, err)
|
||||||
}
|
}
|
||||||
admit := func(state string) *httptest.ResponseRecorder {
|
admit := func(state string) *httptest.ResponseRecorder {
|
||||||
w := httptest.NewRecorder()
|
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
|
return w
|
||||||
}
|
}
|
||||||
if w := admit("compatible"); w.Code != 200 {
|
if w := admit("compatible"); w.Code != 200 {
|
||||||
t.Fatal(w.Code, w.Body.String())
|
t.Fatal(w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
w := httptest.NewRecorder()
|
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 {
|
if w.Code != 200 {
|
||||||
t.Fatal(w.Code, w.Body.String())
|
t.Fatal(w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
host := body["HostConfig"].(map[string]any)
|
host := body["HostConfig"].(map[string]any)
|
||||||
mounts := host["Mounts"].([]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)
|
t.Fatal(body)
|
||||||
}
|
}
|
||||||
var instance store.Instance
|
var instance store.Instance
|
||||||
|
|||||||
+11
-25
@@ -80,40 +80,21 @@ func safeRel(p string) (string, error) {
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func workspacePath(uid string) string { return path.Join("workspaces", uid, "files") }
|
func (a *API) openWorkspace(u store.User, instanceID string) (*os.Root, error) {
|
||||||
|
if instanceID == "" {
|
||||||
func (a *API) openWorkspace(u store.User, instanceIDs ...string) (*os.Root, error) {
|
return nil, errors.New("instance required")
|
||||||
instanceID := ""
|
|
||||||
if len(instanceIDs) > 0 {
|
|
||||||
instanceID = instanceIDs[0]
|
|
||||||
}
|
}
|
||||||
base, err := os.OpenRoot(a.root)
|
base, err := os.OpenRoot(a.root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer base.Close()
|
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")
|
rel := path.Join("workspaces", u.ID, "instances", instanceID, "home")
|
||||||
if err := rootSafe(base, rel, false); err != nil {
|
if err := rootSafe(base, rel, false); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return base.OpenRoot(rel)
|
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 {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return base.OpenRoot(workspacePath(u.ID))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func rootSafe(root *os.Root, rel string, finalMayMissing bool) error {
|
func rootSafe(root *os.Root, rel string, finalMayMissing bool) error {
|
||||||
if rel == "." {
|
if rel == "." {
|
||||||
return nil
|
return nil
|
||||||
@@ -184,6 +165,10 @@ func (a *API) workspace(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.readersMu.Unlock()
|
a.readersMu.Unlock()
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
if r.URL.Path == "/api/ws/usage" {
|
||||||
|
a.wsUsage(w, r, u)
|
||||||
|
return
|
||||||
|
}
|
||||||
user := a.userLocks.Acquire(u.ID)
|
user := a.userLocks.Acquire(u.ID)
|
||||||
root, err := a.openWorkspace(u, instanceID)
|
root, err := a.openWorkspace(u, instanceID)
|
||||||
user.Release()
|
user.Release()
|
||||||
@@ -193,8 +178,6 @@ func (a *API) workspace(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer root.Close()
|
defer root.Close()
|
||||||
switch strings.TrimPrefix(r.URL.Path, "/api/ws/") {
|
switch strings.TrimPrefix(r.URL.Path, "/api/ws/") {
|
||||||
case "usage":
|
|
||||||
a.wsUsage(w, r, root, u)
|
|
||||||
case "tree":
|
case "tree":
|
||||||
a.wsTree(w, r, root, u)
|
a.wsTree(w, r, root, u)
|
||||||
case "mkdir":
|
case "mkdir":
|
||||||
@@ -219,7 +202,7 @@ func entry(root *os.Root, rel string) (os.FileInfo, error) {
|
|||||||
return root.Lstat(rel)
|
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" {
|
if r.Method != "GET" {
|
||||||
fail(w, 405, "method not allowed")
|
fail(w, 405, "method not allowed")
|
||||||
return
|
return
|
||||||
@@ -537,6 +520,9 @@ func (a *API) userUsage(u store.User) (int64, error) {
|
|||||||
}
|
}
|
||||||
defer base.Close()
|
defer base.Close()
|
||||||
root, e := base.OpenRoot(path.Join("workspaces", u.ID))
|
root, e := base.OpenRoot(path.Join("workspaces", u.ID))
|
||||||
|
if errors.Is(e, os.ErrNotExist) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return 0, e
|
return 0, e
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -449,7 +449,10 @@ func TestWorkspaceSafetyAndUpload(t *testing.T) {
|
|||||||
if err := s.CreateUser(u, "unused"); err != nil {
|
if err := s.CreateUser(u, "unused"); err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if e != nil {
|
||||||
t.Fatal(e)
|
t.Fatal(e)
|
||||||
}
|
}
|
||||||
@@ -463,7 +466,7 @@ func TestWorkspaceSafetyAndUpload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
outside := t.TempDir()
|
outside := t.TempDir()
|
||||||
os.WriteFile(filepath.Join(outside, "secret"), []byte("secret"), 0600)
|
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)
|
t.Fatal(e)
|
||||||
}
|
}
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,17 +62,6 @@ func (a *API) checkOrphan(ctx context.Context, id string) error {
|
|||||||
return errors.New("host root unavailable")
|
return errors.New("host root unavailable")
|
||||||
}
|
}
|
||||||
expected := map[string]string{"/workspace": filepath.Join(host, "workspaces", owner, "instances", inst, "home")}
|
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 {
|
for _, m := range c.Mounts {
|
||||||
if m.Type == "tmpfs" && m.Destination == "/tmp" {
|
if m.Type == "tmpfs" && m.Destination == "/tmp" {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestOrphanReclaimGuards(t *testing.T) {
|
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.Run(kind, func(t *testing.T) {
|
||||||
t.Setenv("HOST_DATA_ROOT", "/host/data")
|
t.Setenv("HOST_DATA_ROOT", "/host/data")
|
||||||
id, inst, owner := strings.Repeat("a", 64), strings.Repeat("b", 32), strings.Repeat("c", 32)
|
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" {
|
if kind == "foreign-mount" {
|
||||||
source = "/unrelated"
|
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 {
|
if err := a.s.CreateUser(store.User{ID: owner, Username: "orphan-owner"}, "unused"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
+11
-10
@@ -167,6 +167,10 @@ func (a *API) createRuntime(ctx context.Context, u store.User, i store.Instance,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := a.removeInstanceData(i); err != nil {
|
||||||
|
a.s.SetInstance(i.ID, "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
a.s.DeleteInstance(i.ID)
|
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_networks WHERE instance_id=?", i.ID)
|
||||||
_, _ = a.s.DB.Exec("DELETE FROM runtime_creates 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")
|
return i, errors.New("storage unavailable")
|
||||||
}
|
}
|
||||||
suffix := filepath.Join("workspaces", u.ID)
|
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)
|
dir := filepath.Join(root, relative)
|
||||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||||
return i, errors.New("storage unavailable")
|
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 {
|
var created struct {
|
||||||
ID string `json:"Id"`
|
ID string `json:"Id"`
|
||||||
}
|
}
|
||||||
@@ -415,12 +411,17 @@ func (a *API) instance(w http.ResponseWriter, r *http.Request) {
|
|||||||
fail(w, 502, "network deletion failed")
|
fail(w, 502, "network deletion failed")
|
||||||
return
|
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 {
|
if _, err := a.s.DB.ExecContext(ctx, "DELETE FROM instances WHERE id=?", i.ID); err != nil {
|
||||||
fail(w, 500, "database error")
|
fail(w, 500, "database error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, _ = a.s.DB.Exec("DELETE FROM runtime_networks WHERE instance_id=?", i.ID)
|
_, _ = 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
|
// The instance row is gone and its resources were just cleaned, so
|
||||||
// any pending flags cannot resolve later: drop the row unconditionally.
|
// any pending flags cannot resolve later: drop the row unconditionally.
|
||||||
_, _ = a.s.DB.Exec("DELETE FROM runtime_creates WHERE instance_id=?", i.ID)
|
_, _ = a.s.DB.Exec("DELETE FROM runtime_creates WHERE instance_id=?", i.ID)
|
||||||
|
|||||||
@@ -278,8 +278,8 @@ func TestRuntimeLifecycleAndOwnership(t *testing.T) {
|
|||||||
if w.Code != 200 {
|
if w.Code != 200 {
|
||||||
t.Fatal(w.Code, w.Body.String())
|
t.Fatal(w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(dir); err != nil {
|
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||||
t.Fatal("data removed", err)
|
t.Fatal("instance data retained", err)
|
||||||
}
|
}
|
||||||
if _, err := a.s.GetInstance(i.ID); err == nil {
|
if _, err := a.s.GetInstance(i.ID); err == nil {
|
||||||
t.Fatal("row retained")
|
t.Fatal("row retained")
|
||||||
|
|||||||
+6
-54
@@ -20,7 +20,7 @@ import (
|
|||||||
|
|
||||||
const upgradeSnapshotLimit int64 = 1 << 30
|
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
|
var mounts []map[string]json.RawMessage
|
||||||
if err := json.Unmarshal(host["Mounts"], &mounts); err != nil {
|
if err := json.Unmarshal(host["Mounts"], &mounts); err != nil {
|
||||||
return errors.New("unsupported runtime mounts")
|
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)
|
_ = json.Unmarshal(m[key], &value)
|
||||||
return 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) {
|
if !supportedLayout(recipe) {
|
||||||
return errors.New("unsupported template layout")
|
return errors.New("unsupported template layout; template 7 required")
|
||||||
}
|
}
|
||||||
if recipe != "7" {
|
if len(mounts) != 1 || get(mounts[0], "Type") != "bind" || get(mounts[0], "Source") != home || get(mounts[0], "Target") != "/workspace" {
|
||||||
if get(mounts[homeIndex], "Target") == "/workspace" {
|
return errors.New("independent instance home at /workspace required")
|
||||||
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
|
|
||||||
}
|
}
|
||||||
for _, key := range []string{"Binds", "VolumesFrom"} {
|
for _, key := range []string{"Binds", "VolumesFrom"} {
|
||||||
var values []string
|
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")
|
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)
|
config["Env"], _ = json.Marshal(kept)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -373,12 +326,11 @@ func (a *API) upgrade(w http.ResponseWriter, r *http.Request, i store.Instance)
|
|||||||
hostRoot = a.root
|
hostRoot = a.root
|
||||||
}
|
}
|
||||||
homeSource, homeErr := toHostPath(a.root, hostRoot, filepath.Join(root.Name(), "home"))
|
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 {
|
||||||
if homeErr != nil || sharedErr != nil {
|
|
||||||
fail(w, 500, "host storage unavailable")
|
fail(w, 500, "host storage unavailable")
|
||||||
return
|
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")
|
fail(w, 409, "incompatible runtime workspace layout")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ func TestUpgradeWorkspaceCompatibility(t *testing.T) {
|
|||||||
name, recipe, target string
|
name, recipe, target string
|
||||||
shared, reject bool
|
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},
|
{"single-to-seven", "7", "/workspace", false, false},
|
||||||
{"migrated-to-seven", "7", "/workspace", true, false},
|
{"migrated-to-seven", "7", "/workspace", true, true},
|
||||||
{"legacy-to-six", "6", "/data/dsh-home", true, false},
|
{"legacy-to-six", "6", "/data/dsh-home", true, true},
|
||||||
{"legacy-to-unknown", "", "/data/dsh-home", true, true},
|
{"legacy-to-unknown", "", "/data/dsh-home", true, true},
|
||||||
{"single-to-six", "6", "/workspace", false, true},
|
{"single-to-six", "6", "/workspace", false, true},
|
||||||
{"single-to-unknown", "", "/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}
|
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"]`)}
|
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"])
|
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 {
|
if (err != nil) != tc.reject {
|
||||||
t.Fatalf("error=%v", err)
|
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 tc.reject {
|
||||||
if string(host["Mounts"]) != string(raw) || string(config["Env"]) != before {
|
if string(host["Mounts"]) != string(raw) || string(config["Env"]) != before {
|
||||||
t.Fatal("legacy or rejected config changed")
|
t.Fatal("legacy or rejected config changed")
|
||||||
@@ -71,7 +65,7 @@ func TestUpgradeWorkspaceCompatibility(t *testing.T) {
|
|||||||
t.Fatal(got)
|
t.Fatal(got)
|
||||||
}
|
}
|
||||||
first, _ := json.Marshal([]any{config, host})
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
second, _ := json.Marshal([]any{config, host})
|
second, _ := json.Marshal([]any{config, host})
|
||||||
@@ -90,9 +84,9 @@ func TestUpgradeWorkspaceRejectsAmbiguousMounts(t *testing.T) {
|
|||||||
`{"Type":"bind","Source":"/instance/home","Target":"/workspace"}`,
|
`{"Type":"bind","Source":"/instance/home","Target":"/workspace"}`,
|
||||||
} {
|
} {
|
||||||
config := map[string]json.RawMessage{"Env": json.RawMessage(`["CUSTOM=yes"]`)}
|
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})
|
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)
|
t.Fatal("accepted ambiguous mounts", extra)
|
||||||
}
|
}
|
||||||
after, _ := json.Marshal([]any{config, host})
|
after, _ := json.Marshal([]any{config, host})
|
||||||
@@ -106,7 +100,7 @@ func TestUpgradeLifecycle(t *testing.T) {
|
|||||||
if os.Geteuid() != 0 {
|
if os.Geteuid() != 0 {
|
||||||
t.Skip("requires chown")
|
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) {
|
t.Run(mode, func(t *testing.T) {
|
||||||
image := "sha256:" + strings.Repeat("b", 64)
|
image := "sha256:" + strings.Repeat("b", 64)
|
||||||
var a *API
|
var a *API
|
||||||
@@ -149,7 +143,15 @@ func TestUpgradeLifecycle(t *testing.T) {
|
|||||||
if running {
|
if running {
|
||||||
state = "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/"):
|
case strings.HasPrefix(p, "/images/"):
|
||||||
role := "runtime"
|
role := "runtime"
|
||||||
if mode == "invalid-image" {
|
if mode == "invalid-image" {
|
||||||
@@ -273,8 +275,11 @@ func TestUpgradeLifecycle(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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
|
want := 400
|
||||||
|
if mode == "legacy-layout" || mode == "shared-layout" {
|
||||||
|
want = 409
|
||||||
|
}
|
||||||
if w.Code != want {
|
if w.Code != want {
|
||||||
t.Fatalf("%d %s", w.Code, w.Body.String())
|
t.Fatalf("%d %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function Instances() {
|
|||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
async function operate(i: Instance, operation: string) {
|
async function operate(i: Instance, operation: string) {
|
||||||
if (operation === 'delete') {
|
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') {
|
} else if (operation !== 'start') {
|
||||||
if (!await confirm.ask({title: '停止或重启实例', confirmLabel: '确认执行', body: <>确认停止或重启「{i.name}」?当前连接将中断。</>})) return;
|
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');
|
await api.request(instancePath(i.id) + (operation === 'delete' ? '' : '/' + operation), operation === 'delete' ? 'DELETE' : 'POST');
|
||||||
if (selected?.id === i.id && operation === 'delete') setSelected(undefined);
|
if (selected?.id === i.id && operation === 'delete') setSelected(undefined);
|
||||||
await instances.refresh();
|
await instances.refresh();
|
||||||
|
if (operation === 'delete') await usage.refresh();
|
||||||
}, '服务器已完成操作');
|
}, '服务器已完成操作');
|
||||||
}
|
}
|
||||||
const stats = [
|
const stats = [
|
||||||
|
|||||||
Reference in New Issue
Block a user