diff --git a/.dockerignore b/.dockerignore index 48980a6..e5a8879 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,3 +10,4 @@ output **/*.tsbuildinfo .playwright-cli **/*.sqlite* +image/*.test.mjs diff --git a/.env.example b/.env.example index 5de42e8..dd5ba49 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,11 @@ # Copy to .env (ignored by git). Use an absolute dedicated data directory. DSH_HOST_DATA_ROOT=/var/lib/dsh-mgr DSH_ADMIN_USERNAME=admin -# Supply a unique strong password before starting; no default credential. +# Supply a unique strong password before FIRST start; the panel reads it only +# while the account table is empty. Remove the value after the administrator +# account exists, then run `docker compose up -d --force-recreate panel` +# to remove it from the container environment and `docker inspect` output. +# Restarting the existing container does not update its environment. DSH_ADMIN_PASSWORD= # Numeric group from: stat -c %g /var/run/docker.sock DOCKER_GID= @@ -10,6 +14,8 @@ DSH_DOCKER_ROOT_DIR= # Optional exact extension allowlist, e.g. .txt,.md,.json,.yaml,.go # Empty permits arbitrary workspace files; this is not malware/content scanning. UPLOAD_ALLOWED_EXTENSIONS= -# Optional proxy networks. X-Forwarded-For is ignored unless the peer is in these CIDRs. +# Public origin of the panel exactly as browsers see it (scheme://host, no +# path); controls Origin/CSRF validation and Secure cookies. PANEL_PUBLIC_URL=http://127.0.0.1:8088 +# Optional proxy networks. X-Forwarded-For is ignored unless the peer is in these CIDRs. TRUSTED_PROXY_CIDRS= diff --git a/Dockerfile b/Dockerfile index 334e3be..e8b9732 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,4 +22,5 @@ COPY --from=web /src/web/dist ./web/dist COPY image/ ./image/ USER 1000:1000 EXPOSE 8088 +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD ["/usr/local/bin/dsh-mgr", "-healthcheck"] ENTRYPOINT ["/usr/local/bin/dsh-mgr"] diff --git a/OPERATIONS.md b/OPERATIONS.md index 72f8934..e11ede2 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -4,8 +4,8 @@ ## 启动 -1. 复制 `.env.example` 为 `.env`,填写专用绝对目录 `DSH_HOST_DATA_ROOT` 和 12–72 字节的随机管理员密码。限制 `.env` 的读取权限,不要提交。 -2. 面板以 UID/GID 1000 运行。首次部署前由管理员创建专用数据目录并赋予 1000:1000 所有权、0700 权限;不要对已有未知目录递归改权限。将 `stat -c %g /var/run/docker.sock` 的结果填入 `.env` 的 `DOCKER_GID`,以附加组访问 socket。然后运行 `docker compose up --build -d`。默认访问 `http://127.0.0.1:8088`;远程管理优先使用 SSH 本地端口转发,保留此访问地址。 +1. 复制 `.env.example` 为 `.env`,填写专用绝对目录 `DSH_HOST_DATA_ROOT` 和 12–72 字节的随机管理员密码。限制 `.env` 的读取权限,不要提交。管理员账号创建后该密码不再被读取,应从 `.env` 中移除,再运行 `docker compose up -d --force-recreate panel` 重建面板容器,清除容器环境变量中保留的明文密码(`docker inspect` 可见)。仅修改 `.env` 或重启原容器不会更新其环境变量;重建面板会短暂中断面板访问。 +2. 面板以 UID/GID 1000 运行。首次部署前由管理员创建专用数据目录并赋予 1000:1000 所有权、0700 权限;不要对已有未知目录递归改权限。将 `stat -c %g /var/run/docker.sock` 的结果填入 `.env` 的 `DOCKER_GID`,以附加组访问 socket。然后运行 `docker compose up --build -d`。默认访问 `http://127.0.0.1:8088`;远程管理优先使用 SSH 本地端口转发,保留此访问地址。Compose 定义了 `dsh-mgr -healthcheck` 健康检查(探测本机 `/healthz`);进程无响应时容器会被标记为 unhealthy,配合 `restart: unless-stopped` 在退出时自动拉起。 3. 登录后在镜像页提交精确 DSH 版本或查询最新版再手动构建。已有本地镜像会复用,但必须通过启动验证才允许创建实例。镜像不推送至仓库。 4. 创建实例后从面板进入 DSH。不要给 runtime 发布宿主端口或挂载 Docker socket。 diff --git a/cmd/server/main.go b/cmd/server/main.go index 6cbe0bc..ee222e9 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -7,7 +7,9 @@ import ( "git.misaka.ren/m1saka/dsh-web/internal/api" "git.misaka.ren/m1saka/dsh-web/internal/store" "golang.org/x/crypto/bcrypt" + "io" "log" + "net" "net/http" "os" "os/signal" @@ -18,10 +20,37 @@ import ( ) func main() { + if len(os.Args) > 1 && os.Args[1] == "-healthcheck" { + if err := healthcheck(); err != nil { + log.Printf("healthcheck failed: %v", err) + os.Exit(1) + } + return + } if err := run(); err != nil { log.Fatal(err) } } + +// healthcheck probes the local /healthz endpoint for container health +// reporting; it avoids shipping curl into the runtime image. +func healthcheck() error { + addr := getenv("LISTEN_ADDR", "127.0.0.1:8088") + if host, port, err := net.SplitHostPort(addr); err == nil && (host == "" || host == "0.0.0.0" || host == "::") { + addr = net.JoinHostPort("127.0.0.1", port) + } + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get("http://" + addr + "/healthz") + if err != nil { + return err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("healthz returned %d", resp.StatusCode) + } + return nil +} func run() error { if err := api.ValidateAuthConfig(); err != nil { return err @@ -100,6 +129,7 @@ func run() error { return fmt.Errorf("creation intent recovery: %w", err) } go panel.RunQuotaMonitor(ctx) + go panel.RunSessionCleanup(ctx) return serve(ctx, srv) } diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index c33270a..80a33d2 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -5,6 +5,7 @@ import ( "io" "net" "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -86,3 +87,31 @@ func TestServeWaitsForActiveHandler(t *testing.T) { t.Fatal(err) } } + +func TestHealthcheckProbesLocalEndpoint(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/healthz" { + http.NotFound(w, r) + return + } + w.Write([]byte("ok")) + })) + defer srv.Close() + _, port, err := net.SplitHostPort(srv.Listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + t.Setenv("LISTEN_ADDR", "127.0.0.1:"+port) + if err := healthcheck(); err != nil { + t.Fatalf("healthy server rejected: %v", err) + } + // The container listens on 0.0.0.0; the probe must target loopback. + t.Setenv("LISTEN_ADDR", "0.0.0.0:"+port) + if err := healthcheck(); err != nil { + t.Fatalf("wildcard address rejected: %v", err) + } + t.Setenv("LISTEN_ADDR", "127.0.0.1:1") + if err := healthcheck(); err == nil { + t.Fatal("dead endpoint accepted") + } +} diff --git a/compose.yaml b/compose.yaml index 519c6b8..d89cea1 100644 --- a/compose.yaml +++ b/compose.yaml @@ -20,7 +20,9 @@ services: WEB_DIR: /app/web/dist UPLOAD_ALLOWED_EXTENSIONS: ${UPLOAD_ALLOWED_EXTENSIONS:-} ADMIN_USERNAME: ${DSH_ADMIN_USERNAME:-admin} - ADMIN_PASSWORD: ${DSH_ADMIN_PASSWORD:?Set a strong initial admin password} + # Read only on first startup while the user table is empty; remove the + # value from .env afterwards so the plaintext password is not retained. + ADMIN_PASSWORD: ${DSH_ADMIN_PASSWORD:-} DOCKER_HOST: unix:///var/run/docker.sock DSH_DOCKER_ROOT_VISIBLE: ${DSH_DOCKER_ROOT_DIR:?Set the exact DockerRootDir reported by docker info} PANEL_CONTAINER_ID: dsh-mgr-panel @@ -32,6 +34,14 @@ services: tmpfs: - /tmp:size=256m mem_limit: 1g + cpus: 2 + pids_limit: 512 + healthcheck: + test: ["CMD", "/usr/local/bin/dsh-mgr", "-healthcheck"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s logging: driver: json-file options: diff --git a/internal/api/admin.go b/internal/api/admin.go index 31575ab..6b642b2 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -51,6 +51,10 @@ func (a *API) users(w http.ResponseWriter, r *http.Request) { v.Created = time.Unix(created, 0) out = append(out, v) } + if err := rows.Err(); err != nil { + fail(w, 500, "database error") + return + } writeJSON(w, out) case "POST": var in struct { @@ -208,5 +212,9 @@ func (a *API) audit(w http.ResponseWriter, r *http.Request) { } out = append(out, map[string]any{"id": id, "user_id": uid, "action": action, "target": target, "detail": detail, "created_at": time.Unix(at, 0)}) } + if err := rows.Err(); err != nil { + fail(w, 500, "database error") + return + } writeJSON(w, out) } diff --git a/internal/api/api.go b/internal/api/api.go index 4256bfa..5fb103e 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -19,8 +19,10 @@ type API struct { origin string root string docker *docker.Client - mu sync.Mutex + readersMu sync.Mutex workspaceReaders map[string]int + instanceLocks keyedLocks + userLocks keyedLocks loginMu sync.Mutex loginSources map[string]loginSource trustedProxies []*net.IPNet @@ -29,6 +31,9 @@ type API struct { authKey []byte authErr error authLoginMu sync.Mutex + + // Protected by buildQueue; invalidates image observations across deletions. + imageDeleteGeneration uint64 } func New(s *store.Store, root string) *API { @@ -134,6 +139,11 @@ func (a *API) user(r *http.Request) (store.User, string, error) { } return u, csrf, nil } + +// dummyPasswordHash keeps login latency uniform for unknown usernames so the +// response timing cannot reveal whether an account exists. +var dummyPasswordHash, _ = bcrypt.GenerateFromPassword([]byte("timing-equalization"), bcrypt.DefaultCost) + func (a *API) login(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { fail(w, 405, "method not allowed") @@ -180,6 +190,7 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) { } h, e := a.s.Password(in.Username) if e != nil { + _ = bcrypt.CompareHashAndPassword(dummyPasswordHash, []byte(in.Password)) fail(w, 401, "invalid credentials") return } diff --git a/internal/api/auth_tokens.go b/internal/api/auth_tokens.go index ea77c0e..6124456 100644 --- a/internal/api/auth_tokens.go +++ b/internal/api/auth_tokens.go @@ -1,6 +1,7 @@ package api import ( + "context" "crypto/hmac" "crypto/rand" "crypto/sha256" @@ -9,6 +10,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "log" "net/http" "net/url" "os" @@ -191,6 +193,34 @@ func (a *API) accountLocked(username string) (bool, error) { } return until > time.Now().Unix(), err } + +// RunSessionCleanup removes expired sessions, their refresh tokens and elapsed +// login locks every ten minutes; without it the sessions table grows without +// bound because SessionUser only filters rows on read. +func (a *API) RunSessionCleanup(ctx context.Context) { + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + for { + a.cleanupSessions(time.Now().Unix()) + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func (a *API) cleanupSessions(now int64) { + for _, q := range []struct{ name, sql string }{ + {"refresh tokens", "DELETE FROM auth_refresh WHERE sid IN (SELECT id FROM sessions WHERE expires<=?)"}, + {"sessions", "DELETE FROM sessions WHERE expires<=?"}, + {"login locks", "DELETE FROM auth_failures WHERE locked_until>0 AND locked_until<=?"}, + } { + if _, err := a.s.DB.Exec(q.sql, now); err != nil { + log.Printf("session cleanup (%s): %v", q.name, err) + } + } +} func (a *API) failedLogin(username string) error { _, err := a.s.DB.Exec(`INSERT INTO auth_failures(username,failures,locked_until) VALUES(?,1,0) ON CONFLICT(username) DO UPDATE SET diff --git a/internal/api/auth_tokens_test.go b/internal/api/auth_tokens_test.go index 9eb4f58..9c504aa 100644 --- a/internal/api/auth_tokens_test.go +++ b/internal/api/auth_tokens_test.go @@ -213,3 +213,52 @@ func TestUnsafeAuthKeyFailsClosed(t *testing.T) { t.Fatal(w.Code) } } + +func TestSessionCleanupRemovesExpiredRows(t *testing.T) { + a, _ := authFixture(t) + expired, _, e := a.s.Session("alice") + if e != nil { + t.Fatal(e) + } + current, _, e := a.s.Session("alice") + if e != nil { + t.Fatal(e) + } + past := time.Now().Add(-time.Minute).Unix() + if _, e = a.s.DB.Exec("UPDATE sessions SET expires=? WHERE id=?", past, expired); e != nil { + t.Fatal(e) + } + token, e := refreshToken() + if e != nil { + t.Fatal(e) + } + if _, e = a.s.DB.Exec("INSERT INTO auth_refresh(sid,token_hash) VALUES(?,?)", expired, refreshHash(token)); e != nil { + t.Fatal(e) + } + future := time.Now().Add(time.Hour).Unix() + if _, e = a.s.DB.Exec("INSERT INTO auth_failures(username,failures,locked_until) VALUES('expired-lock',5,?)", past); e != nil { + t.Fatal(e) + } + if _, e = a.s.DB.Exec("INSERT INTO auth_failures(username,failures,locked_until) VALUES('active-lock',5,?)", future); e != nil { + t.Fatal(e) + } + a.cleanupSessions(time.Now().Unix()) + var n int + for _, q := range []string{ + "SELECT count(*) FROM sessions WHERE id='" + expired + "'", + "SELECT count(*) FROM auth_refresh WHERE sid='" + expired + "'", + "SELECT count(*) FROM auth_failures WHERE username='expired-lock'", + } { + if err := a.s.DB.QueryRow(q).Scan(&n); err != nil || n != 0 { + t.Fatalf("expired row retained: %s (%d, %v)", q, n, err) + } + } + for _, q := range []string{ + "SELECT count(*) FROM sessions WHERE id='" + current + "'", + "SELECT count(*) FROM auth_failures WHERE username='active-lock'", + } { + if err := a.s.DB.QueryRow(q).Scan(&n); err != nil || n != 1 { + t.Fatalf("live row removed: %s (%d, %v)", q, n, err) + } + } +} diff --git a/internal/api/config.go b/internal/api/config.go index 8a4990b..b67be0c 100644 --- a/internal/api/config.go +++ b/internal/api/config.go @@ -63,20 +63,23 @@ func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, a } } } - a.mu.Lock() - locked := true + inst := a.instanceLocks.Acquire(i.ID) + locked := inst defer func() { - if locked { - a.mu.Unlock() + if locked != nil { + locked.Release() } }() - if r.Method != http.MethodGet && a.workspaceReaders[i.ID] > 0 { - fail(w, 409, "instance files busy; retry later") - return - } if r.Method != http.MethodGet { - workspaceMu.Lock() - defer workspaceMu.Unlock() + 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, e := a.s.GetInstance(i.ID) if e != nil || current.UID != i.UID { @@ -266,8 +269,8 @@ func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, a fail(w, 400, "invalid backup") return } - a.mu.Unlock() - locked = false + locked.Release() + locked = nil http.ServeContent(w, r, id+".dshbak", st.ModTime(), f) return } diff --git a/internal/api/config_body_test.go b/internal/api/config_body_test.go index 3fb0e1d..54e4a24 100644 --- a/internal/api/config_body_test.go +++ b/internal/api/config_body_test.go @@ -66,17 +66,14 @@ func TestConfigSlowBodyRevalidatesWithoutLocks(t *testing.T) { case <-time.After(3 * time.Second): t.Fatal("body read did not start") } - if !a.mu.TryLock() { - t.Fatal("slow body holds API lock") + if a.instanceLocks.held() || a.userLocks.held() { + t.Fatal("slow body holds API locks") } if change == "lease" { + a.readersMu.Lock() a.workspaceReaders[i.ID]++ + a.readersMu.Unlock() } - a.mu.Unlock() - if !workspaceMu.TryLock() { - t.Fatal("slow body holds workspace lock") - } - workspaceMu.Unlock() want := 409 if change == "deleted" { if _, err := a.s.DB.Exec("DELETE FROM instances WHERE id=?", i.ID); err != nil { @@ -136,17 +133,17 @@ func TestConfigBodyErrorsReturnWithoutLocks(t *testing.T) { r.Header.Set("Content-Type", "multipart/form-data; boundary=test") } w := &configDeadlineWriter{ResponseRecorder: httptest.NewRecorder()} - a.mu.Lock() - workspaceMu.Lock() + inst := a.instanceLocks.Acquire("") + user := a.userLocks.Acquire("") done := make(chan struct{}) go func() { defer close(done); a.config(w, r, store.Instance{}, "backups") }() select { case <-done: - workspaceMu.Unlock() - a.mu.Unlock() + user.Release() + inst.Release() case <-time.After(3 * time.Second): - workspaceMu.Unlock() - a.mu.Unlock() + user.Release() + inst.Release() t.Fatal("body error waited for locks") } if w.Code != tc.want { @@ -191,14 +188,12 @@ func TestConfigSlowResponseReleasesLocks(t *testing.T) { case <-time.After(3 * time.Second): t.Fatal("response write did not start") } - if !a.mu.TryLock() { - t.Fatal("slow response holds API lock") + if a.instanceLocks.held() { + t.Fatal("slow response holds instance lock") } - a.mu.Unlock() - if !workspaceMu.TryLock() { - t.Fatal("slow response holds workspace lock") + if a.userLocks.held() { + t.Fatal("slow response holds user lock") } - workspaceMu.Unlock() }) } diff --git a/internal/api/files.go b/internal/api/files.go index cecdd44..b7024f4 100644 --- a/internal/api/files.go +++ b/internal/api/files.go @@ -13,7 +13,6 @@ import ( "path" "sort" "strings" - "sync" "syscall" "time" @@ -34,8 +33,6 @@ const ( var zipSlots = make(chan struct{}, 4) var errZipLimit = errors.New("archive limit exceeded") -var workspaceMu sync.Mutex - func renameNoReplace(root *os.Root, old, next string) error { anchor, err := root.Open(".") if err != nil { @@ -155,37 +152,41 @@ func (a *API) workspace(w http.ResponseWriter, r *http.Request) { return } if instanceID != "" { - // Pin this instance's home without holding the global lifecycle lock + // Pin this instance's home without holding the instance lifecycle lock // across client reads/writes. Lifecycle mutations reject active leases. - a.mu.Lock() + inst := a.instanceLocks.Acquire(instanceID) i, e := a.s.GetInstance(instanceID) if e != nil || i.UID != u.ID { - a.mu.Unlock() + inst.Release() fail(w, 404, "instance not found") return } if r.Method != http.MethodGet && (i.Status == "error" || a.upgradeRecoveryRequired(i)) { - a.mu.Unlock() + inst.Release() fail(w, 409, "instance requires recovery") return } + a.readersMu.Lock() if a.workspaceReaders == nil { a.workspaceReaders = make(map[string]int) } a.workspaceReaders[instanceID]++ - a.mu.Unlock() + a.readersMu.Unlock() + inst.Release() defer func() { - a.mu.Lock() - a.workspaceReaders[instanceID]-- - if a.workspaceReaders[instanceID] == 0 { - delete(a.workspaceReaders, instanceID) + a.readersMu.Lock() + if a.workspaceReaders != nil { + a.workspaceReaders[instanceID]-- + if a.workspaceReaders[instanceID] == 0 { + delete(a.workspaceReaders, instanceID) + } } - a.mu.Unlock() + a.readersMu.Unlock() }() } - workspaceMu.Lock() + user := a.userLocks.Acquire(u.ID) root, err := a.openWorkspace(u, instanceID) - workspaceMu.Unlock() + user.Release() if err != nil { fail(w, 500, "storage unavailable") return @@ -195,7 +196,7 @@ func (a *API) workspace(w http.ResponseWriter, r *http.Request) { case "usage": a.wsUsage(w, r, root, u) case "tree": - a.wsTree(w, r, root) + a.wsTree(w, r, root, u) case "mkdir": a.wsMkdir(w, r, root, u) case "rename": @@ -223,9 +224,9 @@ func (a *API) wsUsage(w http.ResponseWriter, r *http.Request, root *os.Root, u s fail(w, 405, "method not allowed") return } - workspaceMu.Lock() + user := a.userLocks.Acquire(u.ID) n, e := a.userUsage(u) - workspaceMu.Unlock() + user.Release() if e != nil { fail(w, 500, "usage unavailable") return @@ -233,12 +234,12 @@ func (a *API) wsUsage(w http.ResponseWriter, r *http.Request, root *os.Root, u s writeJSON(w, map[string]int64{"used_bytes": n, "quota_bytes": u.Quota}) } -func (a *API) wsTree(w http.ResponseWriter, r *http.Request, root *os.Root) { - workspaceMu.Lock() - locked := true +func (a *API) wsTree(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) { + user := a.userLocks.Acquire(u.ID) + locked := user defer func() { - if locked { - workspaceMu.Unlock() + if locked != nil { + locked.Release() } }() if r.Method != http.MethodGet { @@ -281,8 +282,8 @@ func (a *API) wsTree(w http.ResponseWriter, r *http.Request, root *os.Root) { out = append(out, map[string]any{"name": de.Name(), "path": p, "is_dir": st.IsDir(), "size": sz, "mod_time": st.ModTime()}) } sort.Slice(out, func(i, j int) bool { return out[i]["name"].(string) < out[j]["name"].(string) }) - workspaceMu.Unlock() - locked = false + locked.Release() + locked = nil writeJSON(w, out) } @@ -299,11 +300,11 @@ func (a *API) wsMkdir(w http.ResponseWriter, r *http.Request, root *os.Root, u s return } p, e := safeRel(x.Path) - workspaceMu.Lock() - locked := true + user := a.userLocks.Acquire(u.ID) + locked := user defer func() { - if locked { - workspaceMu.Unlock() + if locked != nil { + locked.Release() } }() if e != nil || p == "." || rootSafe(root, path.Dir(p), false) != nil { @@ -318,8 +319,8 @@ func (a *API) wsMkdir(w http.ResponseWriter, r *http.Request, root *os.Root, u s _ = f.Chown(1000, 1000) f.Close() } - workspaceMu.Unlock() - locked = false + locked.Release() + locked = nil a.s.AddAudit(u.ID, "workspace.mkdir", p, "") writeJSON(w, map[string]bool{"ok": true}) } @@ -339,11 +340,11 @@ func (a *API) wsRename(w http.ResponseWriter, r *http.Request, root *os.Root, u } p, e := safeRel(x.Path) q, e2 := safeRel(x.NewPath) - workspaceMu.Lock() - locked := true + user := a.userLocks.Acquire(u.ID) + locked := user defer func() { - if locked { - workspaceMu.Unlock() + if locked != nil { + locked.Release() } }() if e != nil || e2 != nil || p == "." || q == "." || rootSafe(root, p, false) != nil || rootSafe(root, path.Dir(q), false) != nil { @@ -363,18 +364,18 @@ func (a *API) wsRename(w http.ResponseWriter, r *http.Request, root *os.Root, u fail(w, 400, "rename failed") return } - workspaceMu.Unlock() - locked = false + locked.Release() + locked = nil a.s.AddAudit(u.ID, "workspace.rename", p, q) writeJSON(w, map[string]bool{"ok": true}) } func (a *API) wsDelete(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) { - workspaceMu.Lock() - locked := true + user := a.userLocks.Acquire(u.ID) + locked := user defer func() { - if locked { - workspaceMu.Unlock() + if locked != nil { + locked.Release() } }() if r.Method != http.MethodDelete { @@ -402,8 +403,8 @@ func (a *API) wsDelete(w http.ResponseWriter, r *http.Request, root *os.Root, u fail(w, 400, "delete failed") return } - workspaceMu.Unlock() - locked = false + locked.Release() + locked = nil a.s.AddAudit(u.ID, "workspace.delete", p, "") writeJSON(w, map[string]bool{"ok": true}) } @@ -437,11 +438,11 @@ func (a *API) wsUpload(w http.ResponseWriter, r *http.Request, root *os.Root, u return } defer r.MultipartForm.RemoveAll() - workspaceMu.Lock() - locked := true + user := a.userLocks.Acquire(u.ID) + locked := user defer func() { - if locked { - workspaceMu.Unlock() + if locked != nil { + locked.Release() } }() dir, e := safeRel(r.FormValue("path")) @@ -503,8 +504,8 @@ func (a *API) wsUpload(w http.ResponseWriter, r *http.Request, root *os.Root, u fail(w, 400, "upload failed") return } - workspaceMu.Unlock() - locked = false + locked.Release() + locked = nil a.s.AddAudit(u.ID, "workspace.upload", target, "") writeJSON(w, map[string]bool{"ok": true}) } diff --git a/internal/api/files_test.go b/internal/api/files_test.go index 49569c0..2158dcd 100644 --- a/internal/api/files_test.go +++ b/internal/api/files_test.go @@ -326,14 +326,12 @@ func TestZipTransferNoLocksAndConcurrencyLimit(t *testing.T) { case <-time.After(2 * time.Second): t.Fatal("download not started") } - if !workspaceMu.TryLock() { - t.Fatal("workspace lock held across write") + if a.userLocks.held() { + t.Fatal("user write lock held across write") } - workspaceMu.Unlock() - if !a.mu.TryLock() { - t.Fatal("lifecycle lock held across write") + if a.instanceLocks.held() { + t.Fatal("instance lock held across write") } - a.mu.Unlock() if len(w.deadlines) != 1 || w.deadlines[0].IsZero() { t.Fatal("missing deadline") } diff --git a/internal/api/images.go b/internal/api/images.go index 67fe895..5a69348 100644 --- a/internal/api/images.go +++ b/internal/api/images.go @@ -75,34 +75,38 @@ func (a *API) builds(w http.ResponseWriter, r *http.Request) { return } } - buildQueue.Lock() - defer buildQueue.Unlock() - if err := a.ensureBuilds(); err != nil { - fail(w, 500, "database error") - return - } if r.Method == http.MethodGet { - rows, err := a.s.DB.Query("SELECT id,version,status,logs,error,image_id,created_at,requested_by,recipe_rev,platform,finished_at FROM image_builds ORDER BY created_at DESC,id DESC") + buildQueue.Lock() + err := a.ensureBuilds() + var rows *sql.Rows + if err == nil { + rows, err = a.s.DB.Query("SELECT id,version,status,logs,error,image_id,created_at,requested_by,recipe_rev,platform,finished_at FROM image_builds ORDER BY created_at DESC,id DESC") + } + result := []buildRecord{} + if err == nil { + for rows.Next() { + var b buildRecord + var finished, recipe, platform sql.NullString + if err = rows.Scan(&b.ID, &b.Version, &b.Status, &b.Logs, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &recipe, &platform, &finished); err != nil { + break + } + b.RecipeRev, b.Platform, b.Finished = recipe.String, platform.String, finished.String + result = append(result, b) + } + scanErr := rows.Err() + rows.Close() + if err == nil { + err = scanErr + } + } + buildQueue.Unlock() if err != nil { fail(w, 500, "database error") return } - result := []buildRecord{} - for rows.Next() { - var b buildRecord - var finished, recipe, platform sql.NullString - if err = rows.Scan(&b.ID, &b.Version, &b.Status, &b.Logs, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &recipe, &platform, &finished); err != nil { - break - } - b.RecipeRev, b.Platform, b.Finished = recipe.String, platform.String, finished.String - result = append(result, b) - } - scanErr := rows.Err() - rows.Close() - if err != nil || scanErr != nil { - fail(w, 500, "database error") - return - } + // Docker image reconciliation is I/O-bound and updates rows with + // compare-and-set, so it must not hold the queue lock that instance + // creation and upgrades also need. if err := a.reconcileBuilds(r.Context(), result); err != nil { fail(w, 502, "docker image reconciliation failed") return @@ -111,7 +115,7 @@ func (a *API) builds(w http.ResponseWriter, r *http.Request) { writeJSON(w, result) return } - b, err := a.enqueueBuildLocked(r.Context(), u.ID, in.Version) + b, err := a.enqueueBuild(r.Context(), u.ID, in.Version) if err != nil { status := 502 if errors.Is(err, errBuildRateLimited) { @@ -128,54 +132,110 @@ func (a *API) builds(w http.ResponseWriter, r *http.Request) { var errBuildRateLimited = errors.New("build limit: three new tasks per user per hour") -func (a *API) enqueueBuild(ctx context.Context, uid, version string) (buildRecord, error) { - buildQueue.Lock() - defer buildQueue.Unlock() - if !validBuildVersion(version) { - return buildRecord{}, errors.New("a fixed version is required") - } - if err := a.ensureBuilds(); err != nil { - return buildRecord{}, err - } - return a.enqueueBuildLocked(ctx, uid, version) -} - -func (a *API) enqueueBuildLocked(ctx context.Context, uid, version string) (buildRecord, error) { +// lookupActiveBuild returns the active or successful record for a version; it +// performs no I/O and must be called with the build queue held. +func (a *API) lookupActiveBuild(version string) (buildRecord, bool, error) { var b buildRecord var finished, recipe, platform sql.NullString err := a.s.DB.QueryRow("SELECT id,version,status,logs,error,image_id,created_at,requested_by,recipe_rev,platform,finished_at FROM image_builds WHERE version=? AND recipe_rev=? AND platform=? AND status IN ('queued','running','validating','success')", version, imageRecipeRev, imagePlatform).Scan(&b.ID, &b.Version, &b.Status, &b.Logs, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &recipe, &platform, &finished) b.RecipeRev, b.Platform, b.Finished = recipe.String, platform.String, finished.String - if err == nil && b.Status == "success" { - ctx, cancel := context.WithTimeout(ctx, 15*time.Second) - id, lookupErr := a.localBuildImage(ctx, b.Version, b.ImageID) + if errors.Is(err, sql.ErrNoRows) { + return b, false, nil + } + if err != nil { + return b, false, err + } + return b, true, nil +} + +func (a *API) enqueueBuild(ctx context.Context, uid, version string) (buildRecord, error) { + if !validBuildVersion(version) { + return buildRecord{}, errors.New("a fixed version is required") + } + buildQueue.Lock() + err := a.ensureBuilds() + var b buildRecord + var found bool + if err == nil { + b, found, err = a.lookupActiveBuild(version) + } + buildQueue.Unlock() + if err != nil { + return b, err + } + if found && b.Status != "success" { + a.startBuildWorker() + return b, nil + } + if found { + // Verifying a previously built image talks to Docker; keep that I/O + // out of the queue lock shared with instance creation and upgrades. + verify, cancel := context.WithTimeout(ctx, 15*time.Second) + id, lookupErr := a.localBuildImage(verify, b.Version, b.ImageID) cancel() if lookupErr != nil { return b, lookupErr } - if id != b.ImageID || id == "" { - if _, err = a.s.DB.Exec("UPDATE image_builds SET status='missing',error='image no longer available' WHERE id=?", b.ID); err == nil { - err = sql.ErrNoRows - } + if id != "" && id == b.ImageID { + a.startBuildWorker() + return b, nil } - } - if errors.Is(err, sql.ErrNoRows) { - var count int - if err := a.s.DB.QueryRow("SELECT count(*) FROM image_build_requests WHERE user_id=? AND created_at>?", uid, time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano)).Scan(&count); err != nil { + buildQueue.Lock() + if _, err = a.s.DB.Exec("UPDATE image_builds SET status='missing',error='image no longer available' WHERE id=? AND status='success'", b.ID); err == nil { + // Another request may have queued a replacement while the image + // was being verified; honor it instead of inserting a duplicate. + b, found, err = a.lookupActiveBuild(version) + } + if err == nil && !found { + b, err = a.insertBuild(uid, version) + } + buildQueue.Unlock() + if err != nil { return b, err } - if count >= 3 { - return b, errBuildRateLimited - } - b = buildRecord{ID: store.ID(), Version: version, Status: "queued", Created: time.Now().UTC().Format(time.RFC3339Nano), RequestedBy: uid, RecipeRev: imageRecipeRev, Platform: imagePlatform} - err = a.insertBuildRequest(b, uid) + a.startBuildWorker() + return b, nil } + buildQueue.Lock() + defer buildQueue.Unlock() + // Re-check after the unlocked phases: another request may have inserted. + b, found, err = a.lookupActiveBuild(version) if err != nil { return b, err } - a.startBuildWorker() + if found { + return b, nil + } + b, err = a.insertBuild(uid, version) + if err != nil { + return b, err + } + a.startBuildWorkerLocked() return b, nil } + +// insertBuild applies the per-user rate limit and inserts a queued record; +// call with the build queue held. +func (a *API) insertBuild(uid, version string) (buildRecord, error) { + var count int + if err := a.s.DB.QueryRow("SELECT count(*) FROM image_build_requests WHERE user_id=? AND created_at>?", uid, time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano)).Scan(&count); err != nil { + return buildRecord{}, err + } + if count >= 3 { + return buildRecord{}, errBuildRateLimited + } + b := buildRecord{ID: store.ID(), Version: version, Status: "queued", Created: time.Now().UTC().Format(time.RFC3339Nano), RequestedBy: uid, RecipeRev: imageRecipeRev, Platform: imagePlatform} + return b, a.insertBuildRequest(b, uid) +} func (a *API) startBuildWorker() { + buildQueue.Lock() + defer buildQueue.Unlock() + a.startBuildWorkerLocked() +} + +// startBuildWorkerLocked requires the caller to hold buildQueue: the active map +// is only safe under that lock. +func (a *API) startBuildWorkerLocked() { if buildQueue.active[a.s.DB] { return } diff --git a/internal/api/images_delete.go b/internal/api/images_delete.go index ea803ac..2deb4c6 100644 --- a/internal/api/images_delete.go +++ b/internal/api/images_delete.go @@ -26,9 +26,11 @@ func (a *API) deleteImage(w http.ResponseWriter, r *http.Request) { fail(w, 400, "invalid image_id") return } - // Upgrades also acquire a.mu before buildQueue. Keep both through deletion. - a.mu.Lock() - defer a.mu.Unlock() + // Hold the build queue through the checks and the Docker deletion so a + // record cannot flip to missing mid-flight. Upgrades write their journal + // entry before creating containers; the transaction below re-checks that + // journal, and an upgrade racing an administrator deletion fails safely + // with its own rollback path. buildQueue.Lock() defer buildQueue.Unlock() if buildQueue.active[a.s.DB] { @@ -43,7 +45,7 @@ func (a *API) deleteImage(w http.ResponseWriter, r *http.Request) { return } defer tx.Rollback() - // Take SQLite's writer lock before checking intents: reservation does not use a.mu. + // Take SQLite's writer lock before checking intents: reservation does not use the build queue. if _, err = tx.ExecContext(ctx, "UPDATE images SET status=status WHERE image_id=?", id); err != nil { fail(w, 500, "image index unavailable") return @@ -132,6 +134,9 @@ func (a *API) deleteImage(w http.ResponseWriter, r *http.Request) { fail(w, status, "docker image deletion failed") return } + // Docker deletion is already authoritative, even if the DB commit fails. + // Any inventory observation taken before it must be discarded. + a.imageDeleteGeneration++ if err = tx.Commit(); err != nil { fail(w, 500, "image deleted but recording failed") return diff --git a/internal/api/images_enqueue_concurrency_test.go b/internal/api/images_enqueue_concurrency_test.go new file mode 100644 index 0000000..8a29d68 --- /dev/null +++ b/internal/api/images_enqueue_concurrency_test.go @@ -0,0 +1,128 @@ +package api + +import ( + "context" + "net/http" + "sync" + "testing" + "time" +) + +func TestEnqueueBuildConcurrentMissingImageReturnsReplacement(t *testing.T) { + inspections := make(chan chan struct{}, 2) + stop := make(chan struct{}) + a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("unexpected Docker method: %s", r.Method) + w.WriteHeader(http.StatusInternalServerError) + return + } + switch r.URL.Path { + case "/v1.47/images/json": + writeJSON(w, []map[string]any{{"Id": "old-image", "Labels": map[string]string{ + "dsh-mgr.role": "runtime", "dsh-mgr.version": "1.2.3", "dsh-mgr.recipe": imageRecipeRev, + }}}) + case "/v1.47/images/old-image/json": + release := make(chan struct{}) + select { + case inspections <- release: + case <-stop: + return + } + select { + case <-release: + w.WriteHeader(http.StatusNotFound) + case <-stop: + case <-r.Context().Done(): + } + default: + t.Errorf("unexpected Docker path: %s", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + } + }) + seedVerifiedImage(t, a, "old-image", "1.2.3") + var oldID string + if err := a.s.DB.QueryRow("SELECT id FROM image_builds WHERE image_id='old-image'").Scan(&oldID); err != nil { + t.Fatal(err) + } + // Keep the replacement queued without starting a background worker. + buildQueue.Lock() + buildQueue.active[a.s.DB] = true + buildQueue.Unlock() + ctx, cancel := context.WithCancel(context.Background()) + var submissions sync.WaitGroup + t.Cleanup(func() { + close(stop) + cancel() + submissions.Wait() + buildQueue.Lock() + delete(buildQueue.active, a.s.DB) + delete(buildQueue.initialized, a.s.DB) + buildQueue.Unlock() + }) + type result struct { + build buildRecord + err error + } + submit := func() <-chan result { + done := make(chan result, 1) + submissions.Add(1) + go func() { + defer submissions.Done() + b, err := a.enqueueBuild(ctx, "u", "1.2.3") + done <- result{b, err} + }() + return done + } + waitInspection := func() chan struct{} { + t.Helper() + select { + case release := <-inspections: + return release + case <-time.After(3 * time.Second): + t.Fatal("submission did not inspect the old image") + return nil + } + } + waitResult := func(done <-chan result) buildRecord { + t.Helper() + select { + case got := <-done: + if got.err != nil { + t.Fatal(got.err) + } + return got.build + case <-time.After(3 * time.Second): + t.Fatal("submission did not finish") + return buildRecord{} + } + } + firstDone := submit() + firstRelease := waitInspection() + secondDone := submit() + secondRelease := waitInspection() + close(firstRelease) + first := waitResult(firstDone) + if first.ID == "" || first.ID == oldID || first.Status != "queued" { + t.Fatalf("first submission did not queue a replacement: %+v", first) + } + close(secondRelease) + second := waitResult(secondDone) + if second.ID != first.ID || second.Status != "queued" { + t.Errorf("second submission returned id=%q status=%q; want replacement id=%q status=queued", second.ID, second.Status, first.ID) + } + var status string + if err := a.s.DB.QueryRow("SELECT status FROM image_builds WHERE id=?", oldID).Scan(&status); err != nil || status != "missing" { + t.Errorf("old build status=%q, err=%v; want missing", status, err) + } + for query, want := range map[string]int{ + "SELECT count(*) FROM image_builds": 2, + "SELECT count(*) FROM image_builds WHERE status='queued'": 1, + "SELECT count(*) FROM image_build_requests": 1, + } { + var count int + if err := a.s.DB.QueryRow(query).Scan(&count); err != nil || count != want { + t.Errorf("%s: count=%d, err=%v; want %d", query, count, err, want) + } + } +} diff --git a/internal/api/images_index_concurrency_test.go b/internal/api/images_index_concurrency_test.go new file mode 100644 index 0000000..2548c6e --- /dev/null +++ b/internal/api/images_index_concurrency_test.go @@ -0,0 +1,140 @@ +package api + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestImageIndexDoesNotUndoConcurrentDelete(t *testing.T) { + for _, initial := range []string{"available", "missing"} { + t.Run(initial, func(t *testing.T) { + id := "sha256:" + strings.Repeat("a", 64) + blocker := "sha256:" + strings.Repeat("b", 64) + reached, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + var reachedOnce sync.Once + var deleted atomic.Bool + unblock := func() { once.Do(func() { close(release) }) } + a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodDelete: + deleted.Store(true) + writeJSON(w, []any{}) + case r.URL.Path == "/v1.47/images/"+id+"/json": + if deleted.Load() { + w.WriteHeader(http.StatusNotFound) + return + } + writeImageInspect(w, id, "1.2.3") + case r.URL.Path == "/v1.47/images/"+blocker+"/json": + reachedOnce.Do(func() { close(reached) }) + select { + case <-release: + case <-r.Context().Done(): + return + } + w.WriteHeader(http.StatusNotFound) + case r.URL.Path == "/v1.47/images/json", r.URL.Path == "/v1.47/containers/json": + writeJSON(w, []any{}) + default: + t.Errorf("unexpected Docker request: %s %s", r.Method, r.URL) + w.WriteHeader(500) + } + }) + defer unblock() + seedVerifiedImage(t, a, id, "1.2.3") + for _, q := range []struct { + sql string + args []any + }{ + {"UPDATE users SET admin=1 WHERE id='u'", nil}, + {"INSERT INTO images(image_id,version,status,built_at) VALUES(?,'1.2.3',?,'2'),(?,'2.0.0','missing','1')", []any{id, initial, blocker}}, + } { + if _, err := a.s.DB.Exec(q.sql, q.args...); err != nil { + t.Fatal(err) + } + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + type outcome struct { + images []imageRecord + err error + } + done := make(chan outcome, 1) + go func() { images, err := a.imageIndex(ctx); done <- outcome{images, err} }() + select { + case <-reached: + case <-ctx.Done(): + t.Fatal("index did not reach second image") + } + r := httptest.NewRequest(http.MethodDelete, "/api/images?image_id="+id, nil).WithContext(ctx) + r.AddCookie(&http.Cookie{Name: "dsh_session", Value: sid}) + addTestAccess(r, sid) + w := httptest.NewRecorder() + a.images(w, r) + if w.Code != 200 { + t.Fatalf("delete: %d %s", w.Code, w.Body.String()) + } + unblock() + select { + case out := <-done: + if out.err != nil { + t.Fatal(out.err) + } + matches := 0 + for _, im := range out.images { + if im.ImageID == id { + matches++ + } + if im.ImageID == id && im.Status != "missing" { + t.Errorf("returned deleted image as %s", im.Status) + } + } + if matches != 1 { + t.Errorf("returned %d records for deleted image, want 1", matches) + } + case <-ctx.Done(): + t.Fatal("index did not finish") + } + var status string + if err := a.s.DB.QueryRow("SELECT status FROM images WHERE image_id=?", id).Scan(&status); err != nil { + t.Fatal(err) + } + if status != "missing" { + t.Errorf("persisted deleted image as %s", status) + } + }) + } +} + +func TestImageIndexBoundsInvalidatedSnapshots(t *testing.T) { + var a *API + var calls atomic.Int32 + a, _ = imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1.47/images/json" { + t.Errorf("unexpected request: %s", r.URL) + w.WriteHeader(500) + return + } + // Model a completed deletion during every unlocked observation phase. + buildQueue.Lock() + a.imageDeleteGeneration++ + buildQueue.Unlock() + calls.Add(1) + writeJSON(w, []any{}) + }) + images, err := a.imageIndex(context.Background()) + if !errors.Is(err, errImageIndexChanged) || images != nil { + t.Fatalf("images=%v err=%v; want no inventory and invalidation error", images, err) + } + if calls.Load() != 3 { + t.Fatalf("attempts=%d, want 3", calls.Load()) + } +} diff --git a/internal/api/images_policy.go b/internal/api/images_policy.go index 33754ce..8559e13 100644 --- a/internal/api/images_policy.go +++ b/internal/api/images_policy.go @@ -2,6 +2,7 @@ package api import ( "context" + "database/sql" "encoding/json" "errors" "fmt" @@ -72,49 +73,69 @@ func (a *API) imageMatches(ctx context.Context, id, version string) (bool, error return im.ID == id && im.platform() == imagePlatform && im.Config.Labels["dsh-mgr.role"] == "runtime" && im.Config.Labels["dsh-mgr.version"] == version && im.Config.Labels["dsh-mgr.recipe"] == imageRecipeRev, nil } +var errImageIndexChanged = errors.New("image inventory changed during reconciliation") + func (a *API) imageIndex(ctx context.Context) ([]imageRecord, error) { + // Retry stale observations without holding the queue across Docker I/O. + // Bound retries so sustained deletions cannot keep a request alive forever. + for attempt := 0; attempt < 3; attempt++ { + result, err := a.imageIndexSnapshot(ctx) + if !errors.Is(err, errImageIndexChanged) { + return result, err + } + } + return nil, errImageIndexChanged +} + +func (a *API) imageIndexSnapshot(ctx context.Context) ([]imageRecord, error) { + // Phase 1: read the managed index under the queue lock (DB only). buildQueue.Lock() - defer buildQueue.Unlock() - if err := a.ensureBuilds(); err != nil { - return nil, err + generation := a.imageDeleteGeneration + err := a.ensureBuilds() + if err == nil { + _, err = a.s.DB.ExecContext(ctx, `INSERT OR IGNORE INTO images(image_id,version,recipe_rev,platform,tag,status,built_at) + SELECT image_id,version,recipe_rev,platform,'','missing',COALESCE(finished_at,created_at) FROM image_builds WHERE status IN ('success','missing') AND image_id<>'' ORDER BY created_at DESC,id DESC`) } - if _, err := a.s.DB.ExecContext(ctx, `INSERT OR IGNORE INTO images(image_id,version,recipe_rev,platform,tag,status,built_at) - SELECT image_id,version,recipe_rev,platform,'','missing',COALESCE(finished_at,created_at) FROM image_builds WHERE status IN ('success','missing') AND image_id<>'' ORDER BY created_at DESC,id DESC`); err != nil { - return nil, err - } - rows, err := a.s.DB.QueryContext(ctx, `SELECT image_id,version,COALESCE(recipe_rev,''),COALESCE(platform,''),tag,status,built_at, + var rows *sql.Rows + if err == nil { + rows, err = a.s.DB.QueryContext(ctx, `SELECT image_id,version,COALESCE(recipe_rev,''),COALESCE(platform,''),tag,status,built_at, (SELECT count(*) FROM instances WHERE instances.image_id=images.image_id) FROM images ORDER BY built_at DESC,image_id`) - if err != nil { - return nil, err } result := []imageRecord{} - for rows.Next() { - var im imageRecord - if err = rows.Scan(&im.ImageID, &im.Version, &im.RecipeRev, &im.Platform, &im.Tag, &im.Status, &im.Created, &im.UsageCount); err != nil { - break + if err == nil { + for rows.Next() { + var im imageRecord + if err = rows.Scan(&im.ImageID, &im.Version, &im.RecipeRev, &im.Platform, &im.Tag, &im.Status, &im.Created, &im.UsageCount); err != nil { + break + } + im.ID = im.ImageID + result = append(result, im) + } + scanErr := rows.Err() + rows.Close() + if err == nil { + err = scanErr } - im.ID = im.ImageID - result = append(result, im) } - scanErr := rows.Err() - rows.Close() + buildQueue.Unlock() if err != nil { return nil, err } - if scanErr != nil { - return nil, scanErr + // Phase 2: inspect Docker without the queue lock; per-image state comes + // from real daemon observations, and instance creation must not wait here. + known := make(map[string]bool, len(result)) + for _, im := range result { + known[im.ImageID] = true } - filters, _ := json.Marshal(map[string][]string{"label": {"dsh-mgr.role=runtime"}}) var local []struct { ID string Labels map[string]string } - if err := a.docker.Do(ctx, http.MethodGet, "/images/json?filters="+url.QueryEscape(string(filters)), nil, &local); err != nil { - return nil, err - } - known := make(map[string]bool, len(result)) - for _, im := range result { - known[im.ImageID] = true + { + filters, _ := json.Marshal(map[string][]string{"label": {"dsh-mgr.role=runtime"}}) + if err := a.docker.Do(ctx, http.MethodGet, "/images/json?filters="+url.QueryEscape(string(filters)), nil, &local); err != nil { + return nil, err + } } for _, im := range local { if im.ID != "" && !known[im.ID] && im.Labels["dsh-mgr.role"] == "runtime" { @@ -154,6 +175,12 @@ func (a *API) imageIndex(ctx context.Context) ([]imageRecord, error) { im.Status = "available" } } + // Phase 3: write observed state back under the queue lock (DB only). + buildQueue.Lock() + defer buildQueue.Unlock() + if generation != a.imageDeleteGeneration { + return nil, errImageIndexChanged + } tx, err := a.s.DB.BeginTx(ctx, nil) if err != nil { return nil, err @@ -179,7 +206,7 @@ func (a *API) StartImageBuilds() error { if err := a.ensureBuilds(); err != nil { return err } - a.startBuildWorker() + a.startBuildWorkerLocked() return nil } diff --git a/internal/api/locks.go b/internal/api/locks.go new file mode 100644 index 0000000..bbe8510 --- /dev/null +++ b/internal/api/locks.go @@ -0,0 +1,56 @@ +package api + +import "sync" + +// keyedLocks hands out one mutex per string key so slow work for one instance +// or user never blocks unrelated work behind a shared global lock. Registry +// entries are removed once the last holder releases, keeping the map bounded. +type keyedLocks struct { + mu sync.Mutex + locks map[string]*keyedLock +} + +type keyedLock struct { + owner *keyedLocks + key string + mu sync.Mutex + refs int +} + +// Acquire registers interest in the key's lock (blocking callers keep the +// registry entry alive) and then blocks until the mutex is held. +func (k *keyedLocks) Acquire(key string) *keyedLock { + k.mu.Lock() + if k.locks == nil { + k.locks = make(map[string]*keyedLock) + } + l, ok := k.locks[key] + if !ok { + l = &keyedLock{owner: k, key: key} + k.locks[key] = l + } + l.refs++ + k.mu.Unlock() + l.mu.Lock() + return l +} + +// Release frees the mutex and drops the registry entry when no holder or +// waiter remains. +func (l *keyedLock) Release() { + l.mu.Unlock() + k := l.owner + k.mu.Lock() + l.refs-- + if l.refs == 0 { + delete(k.locks, l.key) + } + k.mu.Unlock() +} + +// held reports whether any keyed lock is currently held or being waited for. +func (k *keyedLocks) held() bool { + k.mu.Lock() + defer k.mu.Unlock() + return len(k.locks) > 0 +} diff --git a/internal/api/orphans.go b/internal/api/orphans.go index 3ec664a..ece6fc1 100644 --- a/internal/api/orphans.go +++ b/internal/api/orphans.go @@ -101,8 +101,9 @@ func (a *API) orphans(w http.ResponseWriter, r *http.Request) { } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() - a.mu.Lock() - defer a.mu.Unlock() + // checkOrphan re-verifies instance rows, upgrade journal entries and mount + // identities before removal, so no lifecycle lock is needed here: a + // container is only reclaimed when its instance row is already gone. switch r.Method { case "GET": filters, _ := json.Marshal(map[string][]string{"label": {"dsh-mgr.role=dsh"}}) diff --git a/internal/api/quota_write.go b/internal/api/quota_write.go index 0b5ab4a..0ac622a 100644 --- a/internal/api/quota_write.go +++ b/internal/api/quota_write.go @@ -8,7 +8,7 @@ import ( "strings" ) -// Call with workspaceMu held through the write. Runtime writes remain soft-limited. +// Call with the owner's user lock held through the write. Runtime writes remain soft-limited. func (a *API) quotaGrowth(uid string, growth int64) error { var u store.User u.ID = uid diff --git a/internal/api/quota_write_test.go b/internal/api/quota_write_test.go index 2257619..3f33d69 100644 --- a/internal/api/quota_write_test.go +++ b/internal/api/quota_write_test.go @@ -76,8 +76,8 @@ func TestQuotaSerializedWriters(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - workspaceMu.Lock() - defer workspaceMu.Unlock() + user := a.userLocks.Acquire(i.UID) + defer user.Release() if a.quotaGrowth(i.UID, 4) == nil { if err := os.WriteFile(filepath.Join(home, name), []byte("1234"), 0600); err != nil { t.Error(err) diff --git a/internal/api/reconcile.go b/internal/api/reconcile.go index 4bff259..6f62d65 100644 --- a/internal/api/reconcile.go +++ b/internal/api/reconcile.go @@ -11,9 +11,8 @@ import ( // Reconcile checks persisted identities against Docker without deleting unknown // containers. Orphans require explicit administrator review, never blind adoption. +// Runs before the HTTP server starts, so no request can race this pass. func (a *API) Reconcile(ctx context.Context) error { - a.mu.Lock() - defer a.mu.Unlock() filters, _ := json.Marshal(map[string][]string{"label": {"dsh-mgr.instance"}}) var containers []struct { ID string diff --git a/internal/api/runtime.go b/internal/api/runtime.go index 0a9c4b4..64fd2cc 100644 --- a/internal/api/runtime.go +++ b/internal/api/runtime.go @@ -130,8 +130,12 @@ func (a *API) instances(w http.ResponseWriter, r *http.Request) { } func (a *API) createRuntime(ctx context.Context, u store.User, i store.Instance, trustedHost string) (store.Instance, error) { - a.mu.Lock() - defer a.mu.Unlock() + // The instance row is visible to its owner while creating, so hold the + // instance lock for the whole creation: a concurrent DELETE or file lease + // then waits instead of interleaving with container setup. Concurrent + // creations of different instances remain independent. + inst := a.instanceLocks.Acquire(i.ID) + defer inst.Release() if _, err := a.s.GetInstance(i.ID); err != nil { return i, errors.New("instance reservation unavailable") } @@ -317,8 +321,8 @@ func (a *API) instance(w http.ResponseWriter, r *http.Request) { return } if action == "" && r.Method == "DELETE" { - a.mu.Lock() - defer a.mu.Unlock() + inst := a.instanceLocks.Acquire(p[0]) + defer inst.Release() i, err = a.s.GetInstance(p[0]) if err != nil || i.UID != u.ID { fail(w, 404, "not found") @@ -328,7 +332,10 @@ func (a *API) instance(w http.ResponseWriter, r *http.Request) { fail(w, 409, "instance requires recovery") return } - if a.workspaceReaders[i.ID] > 0 { + a.readersMu.Lock() + readers := a.workspaceReaders[i.ID] + a.readersMu.Unlock() + if readers > 0 { fail(w, 409, "instance files busy; retry later") return } @@ -359,8 +366,8 @@ func (a *API) instance(w http.ResponseWriter, r *http.Request) { fail(w, 405, "method not allowed") return } - a.mu.Lock() - defer a.mu.Unlock() + inst := a.instanceLocks.Acquire(p[0]) + defer inst.Release() i, err = a.s.GetInstance(p[0]) if err != nil || i.UID != u.ID { fail(w, 404, "not found") @@ -370,7 +377,10 @@ func (a *API) instance(w http.ResponseWriter, r *http.Request) { fail(w, 409, "instance requires recovery") return } - if a.workspaceReaders[i.ID] > 0 { + a.readersMu.Lock() + readers := a.workspaceReaders[i.ID] + a.readersMu.Unlock() + if readers > 0 { fail(w, 409, "instance files busy; retry later") return } @@ -740,14 +750,18 @@ func (a *API) proxy(w http.ResponseWriter, r *http.Request) { fail(w, 401, "authentication required") return } - a.mu.Lock() - locked := true + p := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/dsh/"), "/", 2) + if p[0] == "" { + fail(w, 404, "not found") + return + } + inst := a.instanceLocks.Acquire(p[0]) + locked := inst defer func() { - if locked { - a.mu.Unlock() + if locked != nil { + locked.Release() } }() - p := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/dsh/"), "/", 2) i, err := a.s.GetInstance(p[0]) if err != nil || i.UID != u.ID { fail(w, 404, "not found") @@ -807,8 +821,8 @@ func (a *API) proxy(w http.ResponseWriter, r *http.Request) { return } cancel() - a.mu.Unlock() - locked = false + locked.Release() + locked = nil proxy := runtimeReverseProxy(target, prefix, host, cookies) a.invalidateRejectedRuntimeCookie(proxy, i, c.State.StartedAt, host, cookies) if bridge { diff --git a/internal/api/runtime_test.go b/internal/api/runtime_test.go index 1848ecf..9da45e8 100644 --- a/internal/api/runtime_test.go +++ b/internal/api/runtime_test.go @@ -67,6 +67,45 @@ func TestRuntimeProxyRejectsErrorState(t *testing.T) { } } +// createRuntime holds the instance lock during creation; a concurrent DELETE +// must wait instead of removing the row while the container is still starting. +func TestInstanceDeleteWaitsForInFlightCreate(t *testing.T) { + a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/networks/") { + w.WriteHeader(http.StatusNoContent) + return + } + t.Errorf("unexpected Docker call %s %s", r.Method, r.URL.Path) + }) + if err := a.s.AddInstance(store.Instance{ID: "creating", UID: "owner", Status: "creating", Created: time.Now()}); err != nil { + t.Fatal(err) + } + inst := a.instanceLocks.Acquire("creating") + done := make(chan *httptest.ResponseRecorder, 1) + go func() { + w := httptest.NewRecorder() + a.instance(w, runtimeRequest(http.MethodDelete, "/api/instances/creating", "", sid)) + done <- w + }() + select { + case w := <-done: + t.Fatalf("delete interleaved with creation: %d %s", w.Code, w.Body.String()) + case <-time.After(300 * time.Millisecond): + } + inst.Release() + select { + case w := <-done: + if w.Code != 200 { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + case <-time.After(5 * time.Second): + t.Fatal("delete stalled after creation finished") + } + if _, err := a.s.GetInstance("creating"); err == nil { + t.Fatal("instance row retained") + } +} + func TestRuntimeCreateLimitAndPolicy(t *testing.T) { if os.Geteuid() != 0 { t.Skip("chown requires root") diff --git a/internal/api/upgrade.go b/internal/api/upgrade.go index 3c7df07..f5d7d6d 100644 --- a/internal/api/upgrade.go +++ b/internal/api/upgrade.go @@ -242,7 +242,7 @@ func upgradeCopy(ctx context.Context, r *os.Root, source, target string, limit i return upgradeSync(r) } -// Call while holding a.mu before any operation that can start or mutate a runtime. +// Call while holding the instance lock before any operation that can start or mutate a runtime. func (a *API) upgradeRecoveryRequired(i store.Instance) bool { var exists int if err := a.s.DB.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='upgrade_journal'").Scan(&exists); err != nil { @@ -275,14 +275,17 @@ func (a *API) upgrade(w http.ResponseWriter, r *http.Request, i store.Instance) fail(w, 400, "invalid image_id") return } - a.mu.Lock() - defer a.mu.Unlock() - if a.workspaceReaders[i.ID] > 0 { + inst := a.instanceLocks.Acquire(i.ID) + defer inst.Release() + a.readersMu.Lock() + readers := a.workspaceReaders[i.ID] + a.readersMu.Unlock() + if readers > 0 { fail(w, 409, "instance files busy; retry later") return } - workspaceMu.Lock() - defer workspaceMu.Unlock() + 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") diff --git a/internal/api/workspace_concurrency_test.go b/internal/api/workspace_concurrency_test.go index 8f49419..30c34df 100644 --- a/internal/api/workspace_concurrency_test.go +++ b/internal/api/workspace_concurrency_test.go @@ -55,11 +55,12 @@ func TestSlowDownloadDoesNotHoldLifecycleLock(t *testing.T) { case <-time.After(3 * time.Second): t.Fatal("download not started") } - if !a.mu.TryLock() { - t.Fatal("slow network holds global lifecycle lock") + if a.instanceLocks.held() { + t.Fatal("slow network holds instance lifecycle lock") } + a.readersMu.Lock() active := a.workspaceReaders["one"] - a.mu.Unlock() + a.readersMu.Unlock() if active != 1 { t.Fatalf("lease count %d", active) } @@ -174,14 +175,12 @@ func TestSlowUploadDoesNotHoldLifecycleLock(t *testing.T) { case <-time.After(3 * time.Second): t.Fatal("upload not reading body") } - if !a.mu.TryLock() { - t.Fatal("slow upload holds global lock") + if a.instanceLocks.held() { + t.Fatal("slow upload holds instance lock") } - a.mu.Unlock() - if !workspaceMu.TryLock() { - t.Fatal("slow upload holds workspace write lock") + if a.userLocks.held() { + t.Fatal("slow upload holds user write lock") } - workspaceMu.Unlock() } func TestWorkspaceErrorResponseReleasesLocks(t *testing.T) { @@ -204,16 +203,96 @@ func TestWorkspaceErrorResponseReleasesLocks(t *testing.T) { case <-time.After(3 * time.Second): t.Fatal("response not started") } - if !workspaceMu.TryLock() { - t.Fatal("error response holds workspace lock") + if a.userLocks.held() { + t.Fatal("error response holds user write lock") } - workspaceMu.Unlock() - if !a.mu.TryLock() { - t.Fatal("error response holds lifecycle lock") + if a.instanceLocks.held() { + t.Fatal("error response holds instance lock") } + a.readersMu.Lock() leases := len(a.workspaceReaders) - a.mu.Unlock() + a.readersMu.Unlock() if leases != 0 { t.Fatal("response retains lease") } } + +// A restore or upgrade holds its instance and user locks for minutes; that +// must never block file operations or lifecycle actions of other users. +func TestLongMutationDoesNotBlockOtherUsers(t *testing.T) { + a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/stop"): + w.WriteHeader(http.StatusNoContent) + case r.Method == "GET" && strings.HasSuffix(r.URL.Path, "/json"): + writeJSON(w, map[string]any{"Id": "cid-two", "State": map[string]any{"Status": "exited", "Running": false}}) + default: + t.Errorf("unexpected Docker call %s %s", r.Method, r.URL.Path) + } + }) + if err := a.s.CreateUser(store.User{ID: "other", Username: "other"}, "unused"); err != nil { + t.Fatal(err) + } + otherSid, _, err := a.s.Session("other") + if err != nil { + t.Fatal(err) + } + registerTestAccess(t, a, otherSid) + if err := a.s.AddInstance(store.Instance{ID: "one", UID: "owner"}); err != nil { + t.Fatal(err) + } + if err := a.s.AddInstance(store.Instance{ID: "two", UID: "other", ContainerID: "cid-two", Status: "running", Created: time.Now()}); err != nil { + t.Fatal(err) + } + for _, dir := range []string{"workspaces/owner/instances/one/home", "workspaces/other/instances/two/home"} { + if err := os.MkdirAll(filepath.Join(a.root, dir), 0750); err != nil { + t.Fatal(err) + } + } + // Simulate a mutation paused mid-flight (restore/upgrade state). + inst := a.instanceLocks.Acquire("one") + defer inst.Release() + user := a.userLocks.Acquire("owner") + defer user.Release() + + treeDone := make(chan *httptest.ResponseRecorder, 1) + go func() { + w := httptest.NewRecorder() + a.workspace(w, runtimeRequest("GET", "/api/ws/tree?instance=two", "", otherSid)) + treeDone <- w + }() + select { + case w := <-treeDone: + if w.Code != 200 { + t.Fatalf("other user tree: %d %s", w.Code, w.Body.String()) + } + case <-time.After(3 * time.Second): + t.Fatal("other user file operation blocked by long mutation") + } + stopDone := make(chan *httptest.ResponseRecorder, 1) + go func() { + w := httptest.NewRecorder() + a.instance(w, runtimeRequest("POST", "/api/instances/two/stop", "", otherSid)) + stopDone <- w + }() + select { + case w := <-stopDone: + if w.Code != 200 { + t.Fatalf("other instance stop: %d %s", w.Code, w.Body.String()) + } + case <-time.After(3 * time.Second): + t.Fatal("other instance lifecycle blocked by long mutation") + } + // The same user's mutation is still serialized by the user lock. + blocked := make(chan struct{}) + go func() { + user := a.userLocks.Acquire("owner") + user.Release() + close(blocked) + }() + select { + case <-blocked: + t.Fatal("owner lock not held during mutation") + case <-time.After(300 * time.Millisecond): + } +} diff --git a/web/package-lock.json b/web/package-lock.json index de320fa..ab214af 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -18,7 +18,7 @@ "@vitejs/plugin-react": "^4.3.4", "typescript": "~5.7.3", "vite": "^6.1.0", - "vitest": "^3.0.5" + "vitest": "^5.0.4" } }, "node_modules/@babel/code-frame": { @@ -1322,117 +1322,12 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/@vitest/expect": { - "version": "3.2.7", - "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-3.2.7.tgz", - "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.7", - "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-3.2.7.tgz", - "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.7", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.7", - "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", - "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.7", - "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-3.2.7.tgz", - "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.7", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.7", - "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-3.2.7.tgz", - "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.7", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/spy": { - "version": "3.2.7", - "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-3.2.7.tgz", - "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-5.0.4.tgz", + "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.7", - "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-3.2.7.tgz", - "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.7", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, "funding": { "url": "https://opencollective.com/vitest" } @@ -1494,16 +1389,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001815", "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", @@ -1526,32 +1411,15 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.3.0", + "resolved": "https://registry.npmmirror.com/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmmirror.com/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1584,16 +1452,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.452", "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", @@ -1602,9 +1460,9 @@ "license": "ISC" }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -1756,13 +1614,6 @@ "node": ">=6" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1774,13 +1625,13 @@ } }, "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "version": "1.4.3", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/monaco-editor": { @@ -1825,21 +1676,18 @@ "node": ">=18" } }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], "license": "MIT", "engines": { - "node": ">= 14.16" + "node": ">=12.20.0" } }, "node_modules/picocolors": { @@ -1984,13 +1832,6 @@ "semver": "bin/semver.js" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/source-map-js": { "version": "1.2.2", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.2.tgz", @@ -2001,53 +1842,32 @@ "node": ">=0.10.0" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "version": "6.2.2", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.17", @@ -2066,36 +1886,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.6", - "resolved": "https://registry.npmmirror.com/tinyspy/-/tinyspy-4.0.6.tgz", - "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.7.3.tgz", @@ -2216,89 +2006,73 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmmirror.com/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitest": { - "version": "3.2.7", - "resolved": "https://registry.npmmirror.com/vitest/-/vitest-3.2.7.tgz", - "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-5.0.4.tgz", + "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.7", - "@vitest/mocker": "3.2.7", - "@vitest/pretty-format": "^3.2.7", - "@vitest/runner": "3.2.7", - "@vitest/snapshot": "3.2.7", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" + "@vitest/mocker": "5.0.4", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.7", - "@vitest/ui": "3.2.7", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.4", + "@vitest/browser-preview": "5.0.4", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.4", + "@vitest/coverage-v8": "5.0.4", + "@vitest/ui": "5.0.4", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -2309,24 +2083,51 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-5.0.4.tgz", + "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.4", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true } } }, "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, "bin": { "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=20.11" } }, "node_modules/yallist": { diff --git a/web/package.json b/web/package.json index 3a123d0..35c37df 100644 --- a/web/package.json +++ b/web/package.json @@ -20,6 +20,6 @@ "@vitejs/plugin-react": "^4.3.4", "typescript": "~5.7.3", "vite": "^6.1.0", - "vitest": "^3.0.5" + "vitest": "^5.0.4" } } diff --git a/web/src/CreateInstance.tsx b/web/src/CreateInstance.tsx index 5240f75..744af69 100644 --- a/web/src/CreateInstance.tsx +++ b/web/src/CreateInstance.tsx @@ -1,13 +1,14 @@ import { useEffect, useRef, useState, type FormEvent } from 'react'; import { api, type Image, type Instance } from './api'; -import { ResourceState, useAction, useResource } from './ui'; +import { message, ResourceState, useAction, useResource } from './ui'; export function CreateInstance({close, refresh}: {close: () => void; refresh: () => Promise}) { const images = useResource(api.images), action = useAction(); const [mode, setMode] = useState('local'), [progress, setProgress] = useState(''); const [version, setVersion] = useState(''); + const [pollError, setPollError] = useState(''); const [intents, setIntents] = useState<{id:string; name:string; version:string; status:string; error:string}[]>([]); - useEffect(() => {let live=true; const load=()=>api.request('/intents').then(v=>{if(live)setIntents(v);}).catch(e=>{if(live)setProgress(String(e));}); void load(); const timer=window.setInterval(load,2000); return ()=>{live=false;window.clearInterval(timer);};}, []); + useEffect(() => {let live=true; const load=()=>api.request('/intents').then(v=>{if(live){setIntents(v);setPollError('');}}).catch(e=>{if(live)setPollError(message(e));}); void load(); const timer=window.setInterval(load,2000); return ()=>{live=false;window.clearInterval(timer);};}, []); const active = useRef(true); useEffect(() => {active.current = true; return () => {active.current = false;};}, []); const available = images.data?.filter(i => !i.status || i.status === 'available') || []; @@ -36,5 +37,5 @@ export function CreateInstance({close, refresh}: {close: () => void; refresh: () }); if (active.current) setProgress(''); } - return

创建独立实例

{action.feedback}

确认后服务器持久预留一个名额(每用户最多两个)。刷新或关闭不会取消自动创建;失败释放名额,不回退其他版本。取消等待不会取消共享构建,创建开始后不可撤回。

{mode === 'local' && <>{!images.loading && !available.length &&

暂无可用镜像,可选择构建最新版并创建。

}}{mode === 'latest' && version &&

将锁定精确版本 {version},确认后预留名额并构建创建。

}
{progress &&

{progress}

}
    {intents.map(i =>
  • {i.name} · {i.version} · {i.status} {i.error}{i.status === 'waiting' && }{i.status === 'success' && }
  • )}
; + return

创建独立实例

{action.feedback}

确认后服务器持久预留一个名额(每用户最多两个)。刷新或关闭不会取消自动创建;失败释放名额,不回退其他版本。取消等待不会取消共享构建,创建开始后不可撤回。

{mode === 'local' && <>{!images.loading && !available.length &&

暂无可用镜像,可选择构建最新版并创建。

}}{mode === 'latest' && version &&

将锁定精确版本 {version},确认后预留名额并构建创建。

}
{progress &&

{progress}

}{pollError &&

意图刷新失败:{pollError}

}
    {intents.map(i =>
  • {i.name} · {i.version} · {i.status} {i.error}{i.status === 'waiting' && }{i.status === 'success' && }
  • )}
; } diff --git a/web/src/Orphans.tsx b/web/src/Orphans.tsx index 0096ea6..41050ab 100644 --- a/web/src/Orphans.tsx +++ b/web/src/Orphans.tsx @@ -6,6 +6,6 @@ export function Orphans() {

仅列出已停止、归属和挂载核验通过且无实例或升级引用的容器。回收保留所有挂载数据,不删除网络。运行中及来源不明的容器不会列入。

{action.feedback} {list.data?.length===0 &&

没有可安全回收的容器。

} - {list.data?.map(id=>
{id}
)} + {list.data?.map(id=>
{id}
)} ; }