Fix audit findings: lock architecture, security, deployment hardening

Close all 14 findings from the codebase audit (1 high, 5 medium, 8 low):

- Locking: replace the global API and workspace mutexes with per-instance
  and per-user keyed locks (new internal/api/locks.go) so a slow restore,
  upgrade or transfer no longer blocks unrelated users or instances; move
  Docker I/O out of the build queue and invalidate concurrent image-index
  observations with a generation counter; serialize instance deletion
  behind in-flight creation.
- Security: equalize login timing for unknown usernames, periodically
  purge expired sessions/refresh tokens/lock rows, URL-encode orphan IDs
  in the frontend, upgrade vitest 3.x->5.x to clear 3 npm audit
  vulnerabilities (2 critical RCE).
- Deployment: add a -healthcheck mode probing /healthz (used by Dockerfile
  HEALTHCHECK and compose), make ADMIN_PASSWORD optional in compose with
  removal guidance after first start, exclude test scripts from the image,
  and cap panel resources (cpus: 2, pids_limit: 512).
- Reliability: check rows.Err() on admin listing loops, surface intent
  polling errors without sticking, disable close during in-flight create.

Verified: go build/vet, full go test -race -count=1 ./..., frontend
tsc build + 32 tests, npm audit 0, compose config, real-container
healthcheck.
This commit is contained in:
2026-10-13 20:59:28 +08:00
parent 85332bbc56
commit 66abb76318
33 changed files with 1065 additions and 540 deletions
+1
View File
@@ -10,3 +10,4 @@ output
**/*.tsbuildinfo **/*.tsbuildinfo
.playwright-cli .playwright-cli
**/*.sqlite* **/*.sqlite*
image/*.test.mjs
+8 -2
View File
@@ -1,7 +1,11 @@
# Copy to .env (ignored by git). Use an absolute dedicated data directory. # Copy to .env (ignored by git). Use an absolute dedicated data directory.
DSH_HOST_DATA_ROOT=/var/lib/dsh-mgr DSH_HOST_DATA_ROOT=/var/lib/dsh-mgr
DSH_ADMIN_USERNAME=admin 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= DSH_ADMIN_PASSWORD=
# Numeric group from: stat -c %g /var/run/docker.sock # Numeric group from: stat -c %g /var/run/docker.sock
DOCKER_GID= DOCKER_GID=
@@ -10,6 +14,8 @@ DSH_DOCKER_ROOT_DIR=
# Optional exact extension allowlist, e.g. .txt,.md,.json,.yaml,.go # Optional exact extension allowlist, e.g. .txt,.md,.json,.yaml,.go
# Empty permits arbitrary workspace files; this is not malware/content scanning. # Empty permits arbitrary workspace files; this is not malware/content scanning.
UPLOAD_ALLOWED_EXTENSIONS= 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 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= TRUSTED_PROXY_CIDRS=
+1
View File
@@ -22,4 +22,5 @@ COPY --from=web /src/web/dist ./web/dist
COPY image/ ./image/ COPY image/ ./image/
USER 1000:1000 USER 1000:1000
EXPOSE 8088 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"] ENTRYPOINT ["/usr/local/bin/dsh-mgr"]
+2 -2
View File
@@ -4,8 +4,8 @@
## 启动 ## 启动
1. 复制 `.env.example` 为 `.env`,填写专用绝对目录 `DSH_HOST_DATA_ROOT` 和 12–72 字节的随机管理员密码。限制 `.env` 的读取权限,不要提交。 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 本地端口转发,保留此访问地址。 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 版本或查询最新版再手动构建。已有本地镜像会复用,但必须通过启动验证才允许创建实例。镜像不推送至仓库。 3. 登录后在镜像页提交精确 DSH 版本或查询最新版再手动构建。已有本地镜像会复用,但必须通过启动验证才允许创建实例。镜像不推送至仓库。
4. 创建实例后从面板进入 DSH。不要给 runtime 发布宿主端口或挂载 Docker socket。 4. 创建实例后从面板进入 DSH。不要给 runtime 发布宿主端口或挂载 Docker socket。
+30
View File
@@ -7,7 +7,9 @@ import (
"git.misaka.ren/m1saka/dsh-web/internal/api" "git.misaka.ren/m1saka/dsh-web/internal/api"
"git.misaka.ren/m1saka/dsh-web/internal/store" "git.misaka.ren/m1saka/dsh-web/internal/store"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"io"
"log" "log"
"net"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -18,10 +20,37 @@ import (
) )
func main() { 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 { if err := run(); err != nil {
log.Fatal(err) 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 { func run() error {
if err := api.ValidateAuthConfig(); err != nil { if err := api.ValidateAuthConfig(); err != nil {
return err return err
@@ -100,6 +129,7 @@ func run() error {
return fmt.Errorf("creation intent recovery: %w", err) return fmt.Errorf("creation intent recovery: %w", err)
} }
go panel.RunQuotaMonitor(ctx) go panel.RunQuotaMonitor(ctx)
go panel.RunSessionCleanup(ctx)
return serve(ctx, srv) return serve(ctx, srv)
} }
+29
View File
@@ -5,6 +5,7 @@ import (
"io" "io"
"net" "net"
"net/http" "net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -86,3 +87,31 @@ func TestServeWaitsForActiveHandler(t *testing.T) {
t.Fatal(err) 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")
}
}
+11 -1
View File
@@ -20,7 +20,9 @@ services:
WEB_DIR: /app/web/dist WEB_DIR: /app/web/dist
UPLOAD_ALLOWED_EXTENSIONS: ${UPLOAD_ALLOWED_EXTENSIONS:-} UPLOAD_ALLOWED_EXTENSIONS: ${UPLOAD_ALLOWED_EXTENSIONS:-}
ADMIN_USERNAME: ${DSH_ADMIN_USERNAME:-admin} 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 DOCKER_HOST: unix:///var/run/docker.sock
DSH_DOCKER_ROOT_VISIBLE: ${DSH_DOCKER_ROOT_DIR:?Set the exact DockerRootDir reported by docker info} DSH_DOCKER_ROOT_VISIBLE: ${DSH_DOCKER_ROOT_DIR:?Set the exact DockerRootDir reported by docker info}
PANEL_CONTAINER_ID: dsh-mgr-panel PANEL_CONTAINER_ID: dsh-mgr-panel
@@ -32,6 +34,14 @@ services:
tmpfs: tmpfs:
- /tmp:size=256m - /tmp:size=256m
mem_limit: 1g 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: logging:
driver: json-file driver: json-file
options: options:
+8
View File
@@ -51,6 +51,10 @@ func (a *API) users(w http.ResponseWriter, r *http.Request) {
v.Created = time.Unix(created, 0) v.Created = time.Unix(created, 0)
out = append(out, v) out = append(out, v)
} }
if err := rows.Err(); err != nil {
fail(w, 500, "database error")
return
}
writeJSON(w, out) writeJSON(w, out)
case "POST": case "POST":
var in struct { 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)}) 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) writeJSON(w, out)
} }
+12 -1
View File
@@ -19,8 +19,10 @@ type API struct {
origin string origin string
root string root string
docker *docker.Client docker *docker.Client
mu sync.Mutex readersMu sync.Mutex
workspaceReaders map[string]int workspaceReaders map[string]int
instanceLocks keyedLocks
userLocks keyedLocks
loginMu sync.Mutex loginMu sync.Mutex
loginSources map[string]loginSource loginSources map[string]loginSource
trustedProxies []*net.IPNet trustedProxies []*net.IPNet
@@ -29,6 +31,9 @@ type API struct {
authKey []byte authKey []byte
authErr error authErr error
authLoginMu sync.Mutex authLoginMu sync.Mutex
// Protected by buildQueue; invalidates image observations across deletions.
imageDeleteGeneration uint64
} }
func New(s *store.Store, root string) *API { 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 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) { func (a *API) login(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
fail(w, 405, "method not allowed") 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) h, e := a.s.Password(in.Username)
if e != nil { if e != nil {
_ = bcrypt.CompareHashAndPassword(dummyPasswordHash, []byte(in.Password))
fail(w, 401, "invalid credentials") fail(w, 401, "invalid credentials")
return return
} }
+30
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"context"
"crypto/hmac" "crypto/hmac"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
@@ -9,6 +10,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"log"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -191,6 +193,34 @@ func (a *API) accountLocked(username string) (bool, error) {
} }
return until > time.Now().Unix(), err 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 { func (a *API) failedLogin(username string) error {
_, err := a.s.DB.Exec(`INSERT INTO auth_failures(username,failures,locked_until) VALUES(?,1,0) _, err := a.s.DB.Exec(`INSERT INTO auth_failures(username,failures,locked_until) VALUES(?,1,0)
ON CONFLICT(username) DO UPDATE SET ON CONFLICT(username) DO UPDATE SET
+49
View File
@@ -213,3 +213,52 @@ func TestUnsafeAuthKeyFailsClosed(t *testing.T) {
t.Fatal(w.Code) 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)
}
}
}
+13 -10
View File
@@ -63,20 +63,23 @@ func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, a
} }
} }
} }
a.mu.Lock() inst := a.instanceLocks.Acquire(i.ID)
locked := true locked := inst
defer func() { defer func() {
if locked { if locked != nil {
a.mu.Unlock() locked.Release()
} }
}() }()
if r.Method != http.MethodGet && a.workspaceReaders[i.ID] > 0 { if r.Method != http.MethodGet {
a.readersMu.Lock()
readers := a.workspaceReaders[i.ID]
a.readersMu.Unlock()
if readers > 0 {
fail(w, 409, "instance files busy; retry later") fail(w, 409, "instance files busy; retry later")
return return
} }
if r.Method != http.MethodGet { user := a.userLocks.Acquire(i.UID)
workspaceMu.Lock() defer user.Release()
defer workspaceMu.Unlock()
} }
current, e := a.s.GetInstance(i.ID) current, e := a.s.GetInstance(i.ID)
if e != nil || current.UID != i.UID { 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") fail(w, 400, "invalid backup")
return return
} }
a.mu.Unlock() locked.Release()
locked = false locked = nil
http.ServeContent(w, r, id+".dshbak", st.ModTime(), f) http.ServeContent(w, r, id+".dshbak", st.ModTime(), f)
return return
} }
+14 -19
View File
@@ -66,17 +66,14 @@ func TestConfigSlowBodyRevalidatesWithoutLocks(t *testing.T) {
case <-time.After(3 * time.Second): case <-time.After(3 * time.Second):
t.Fatal("body read did not start") t.Fatal("body read did not start")
} }
if !a.mu.TryLock() { if a.instanceLocks.held() || a.userLocks.held() {
t.Fatal("slow body holds API lock") t.Fatal("slow body holds API locks")
} }
if change == "lease" { if change == "lease" {
a.readersMu.Lock()
a.workspaceReaders[i.ID]++ a.workspaceReaders[i.ID]++
a.readersMu.Unlock()
} }
a.mu.Unlock()
if !workspaceMu.TryLock() {
t.Fatal("slow body holds workspace lock")
}
workspaceMu.Unlock()
want := 409 want := 409
if change == "deleted" { if change == "deleted" {
if _, err := a.s.DB.Exec("DELETE FROM instances WHERE id=?", i.ID); err != nil { 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") r.Header.Set("Content-Type", "multipart/form-data; boundary=test")
} }
w := &configDeadlineWriter{ResponseRecorder: httptest.NewRecorder()} w := &configDeadlineWriter{ResponseRecorder: httptest.NewRecorder()}
a.mu.Lock() inst := a.instanceLocks.Acquire("")
workspaceMu.Lock() user := a.userLocks.Acquire("")
done := make(chan struct{}) done := make(chan struct{})
go func() { defer close(done); a.config(w, r, store.Instance{}, "backups") }() go func() { defer close(done); a.config(w, r, store.Instance{}, "backups") }()
select { select {
case <-done: case <-done:
workspaceMu.Unlock() user.Release()
a.mu.Unlock() inst.Release()
case <-time.After(3 * time.Second): case <-time.After(3 * time.Second):
workspaceMu.Unlock() user.Release()
a.mu.Unlock() inst.Release()
t.Fatal("body error waited for locks") t.Fatal("body error waited for locks")
} }
if w.Code != tc.want { if w.Code != tc.want {
@@ -191,14 +188,12 @@ func TestConfigSlowResponseReleasesLocks(t *testing.T) {
case <-time.After(3 * time.Second): case <-time.After(3 * time.Second):
t.Fatal("response write did not start") t.Fatal("response write did not start")
} }
if !a.mu.TryLock() { if a.instanceLocks.held() {
t.Fatal("slow response holds API lock") t.Fatal("slow response holds instance lock")
} }
a.mu.Unlock() if a.userLocks.held() {
if !workspaceMu.TryLock() { t.Fatal("slow response holds user lock")
t.Fatal("slow response holds workspace lock")
} }
workspaceMu.Unlock()
}) })
} }
+47 -46
View File
@@ -13,7 +13,6 @@ import (
"path" "path"
"sort" "sort"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
@@ -34,8 +33,6 @@ const (
var zipSlots = make(chan struct{}, 4) var zipSlots = make(chan struct{}, 4)
var errZipLimit = errors.New("archive limit exceeded") var errZipLimit = errors.New("archive limit exceeded")
var workspaceMu sync.Mutex
func renameNoReplace(root *os.Root, old, next string) error { func renameNoReplace(root *os.Root, old, next string) error {
anchor, err := root.Open(".") anchor, err := root.Open(".")
if err != nil { if err != nil {
@@ -155,37 +152,41 @@ func (a *API) workspace(w http.ResponseWriter, r *http.Request) {
return return
} }
if instanceID != "" { 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. // across client reads/writes. Lifecycle mutations reject active leases.
a.mu.Lock() inst := a.instanceLocks.Acquire(instanceID)
i, e := a.s.GetInstance(instanceID) i, e := a.s.GetInstance(instanceID)
if e != nil || i.UID != u.ID { if e != nil || i.UID != u.ID {
a.mu.Unlock() inst.Release()
fail(w, 404, "instance not found") fail(w, 404, "instance not found")
return return
} }
if r.Method != http.MethodGet && (i.Status == "error" || a.upgradeRecoveryRequired(i)) { if r.Method != http.MethodGet && (i.Status == "error" || a.upgradeRecoveryRequired(i)) {
a.mu.Unlock() inst.Release()
fail(w, 409, "instance requires recovery") fail(w, 409, "instance requires recovery")
return return
} }
a.readersMu.Lock()
if a.workspaceReaders == nil { if a.workspaceReaders == nil {
a.workspaceReaders = make(map[string]int) a.workspaceReaders = make(map[string]int)
} }
a.workspaceReaders[instanceID]++ a.workspaceReaders[instanceID]++
a.mu.Unlock() a.readersMu.Unlock()
inst.Release()
defer func() { defer func() {
a.mu.Lock() a.readersMu.Lock()
if a.workspaceReaders != nil {
a.workspaceReaders[instanceID]-- a.workspaceReaders[instanceID]--
if a.workspaceReaders[instanceID] == 0 { if a.workspaceReaders[instanceID] == 0 {
delete(a.workspaceReaders, instanceID) delete(a.workspaceReaders, instanceID)
} }
a.mu.Unlock() }
a.readersMu.Unlock()
}() }()
} }
workspaceMu.Lock() user := a.userLocks.Acquire(u.ID)
root, err := a.openWorkspace(u, instanceID) root, err := a.openWorkspace(u, instanceID)
workspaceMu.Unlock() user.Release()
if err != nil { if err != nil {
fail(w, 500, "storage unavailable") fail(w, 500, "storage unavailable")
return return
@@ -195,7 +196,7 @@ func (a *API) workspace(w http.ResponseWriter, r *http.Request) {
case "usage": case "usage":
a.wsUsage(w, r, root, u) a.wsUsage(w, r, root, u)
case "tree": case "tree":
a.wsTree(w, r, root) a.wsTree(w, r, root, u)
case "mkdir": case "mkdir":
a.wsMkdir(w, r, root, u) a.wsMkdir(w, r, root, u)
case "rename": 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") fail(w, 405, "method not allowed")
return return
} }
workspaceMu.Lock() user := a.userLocks.Acquire(u.ID)
n, e := a.userUsage(u) n, e := a.userUsage(u)
workspaceMu.Unlock() user.Release()
if e != nil { if e != nil {
fail(w, 500, "usage unavailable") fail(w, 500, "usage unavailable")
return 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}) 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) { func (a *API) wsTree(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
workspaceMu.Lock() user := a.userLocks.Acquire(u.ID)
locked := true locked := user
defer func() { defer func() {
if locked { if locked != nil {
workspaceMu.Unlock() locked.Release()
} }
}() }()
if r.Method != http.MethodGet { 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()}) 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) }) sort.Slice(out, func(i, j int) bool { return out[i]["name"].(string) < out[j]["name"].(string) })
workspaceMu.Unlock() locked.Release()
locked = false locked = nil
writeJSON(w, out) writeJSON(w, out)
} }
@@ -299,11 +300,11 @@ func (a *API) wsMkdir(w http.ResponseWriter, r *http.Request, root *os.Root, u s
return return
} }
p, e := safeRel(x.Path) p, e := safeRel(x.Path)
workspaceMu.Lock() user := a.userLocks.Acquire(u.ID)
locked := true locked := user
defer func() { defer func() {
if locked { if locked != nil {
workspaceMu.Unlock() locked.Release()
} }
}() }()
if e != nil || p == "." || rootSafe(root, path.Dir(p), false) != nil { 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.Chown(1000, 1000)
f.Close() f.Close()
} }
workspaceMu.Unlock() locked.Release()
locked = false locked = nil
a.s.AddAudit(u.ID, "workspace.mkdir", p, "") a.s.AddAudit(u.ID, "workspace.mkdir", p, "")
writeJSON(w, map[string]bool{"ok": true}) 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) p, e := safeRel(x.Path)
q, e2 := safeRel(x.NewPath) q, e2 := safeRel(x.NewPath)
workspaceMu.Lock() user := a.userLocks.Acquire(u.ID)
locked := true locked := user
defer func() { defer func() {
if locked { if locked != nil {
workspaceMu.Unlock() locked.Release()
} }
}() }()
if e != nil || e2 != nil || p == "." || q == "." || rootSafe(root, p, false) != nil || rootSafe(root, path.Dir(q), false) != nil { 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") fail(w, 400, "rename failed")
return return
} }
workspaceMu.Unlock() locked.Release()
locked = false locked = nil
a.s.AddAudit(u.ID, "workspace.rename", p, q) a.s.AddAudit(u.ID, "workspace.rename", p, q)
writeJSON(w, map[string]bool{"ok": true}) writeJSON(w, map[string]bool{"ok": true})
} }
func (a *API) wsDelete(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) { func (a *API) wsDelete(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
workspaceMu.Lock() user := a.userLocks.Acquire(u.ID)
locked := true locked := user
defer func() { defer func() {
if locked { if locked != nil {
workspaceMu.Unlock() locked.Release()
} }
}() }()
if r.Method != http.MethodDelete { 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") fail(w, 400, "delete failed")
return return
} }
workspaceMu.Unlock() locked.Release()
locked = false locked = nil
a.s.AddAudit(u.ID, "workspace.delete", p, "") a.s.AddAudit(u.ID, "workspace.delete", p, "")
writeJSON(w, map[string]bool{"ok": true}) 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 return
} }
defer r.MultipartForm.RemoveAll() defer r.MultipartForm.RemoveAll()
workspaceMu.Lock() user := a.userLocks.Acquire(u.ID)
locked := true locked := user
defer func() { defer func() {
if locked { if locked != nil {
workspaceMu.Unlock() locked.Release()
} }
}() }()
dir, e := safeRel(r.FormValue("path")) 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") fail(w, 400, "upload failed")
return return
} }
workspaceMu.Unlock() locked.Release()
locked = false locked = nil
a.s.AddAudit(u.ID, "workspace.upload", target, "") a.s.AddAudit(u.ID, "workspace.upload", target, "")
writeJSON(w, map[string]bool{"ok": true}) writeJSON(w, map[string]bool{"ok": true})
} }
+4 -6
View File
@@ -326,14 +326,12 @@ func TestZipTransferNoLocksAndConcurrencyLimit(t *testing.T) {
case <-time.After(2 * time.Second): case <-time.After(2 * time.Second):
t.Fatal("download not started") t.Fatal("download not started")
} }
if !workspaceMu.TryLock() { if a.userLocks.held() {
t.Fatal("workspace lock held across write") t.Fatal("user write lock held across write")
} }
workspaceMu.Unlock() if a.instanceLocks.held() {
if !a.mu.TryLock() { t.Fatal("instance lock held across write")
t.Fatal("lifecycle lock held across write")
} }
a.mu.Unlock()
if len(w.deadlines) != 1 || w.deadlines[0].IsZero() { if len(w.deadlines) != 1 || w.deadlines[0].IsZero() {
t.Fatal("missing deadline") t.Fatal("missing deadline")
} }
+102 -42
View File
@@ -75,19 +75,15 @@ func (a *API) builds(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
buildQueue.Lock()
defer buildQueue.Unlock()
if err := a.ensureBuilds(); err != nil {
fail(w, 500, "database error")
return
}
if r.Method == http.MethodGet { 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()
if err != nil { err := a.ensureBuilds()
fail(w, 500, "database error") var rows *sql.Rows
return 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{} result := []buildRecord{}
if err == nil {
for rows.Next() { for rows.Next() {
var b buildRecord var b buildRecord
var finished, recipe, platform sql.NullString var finished, recipe, platform sql.NullString
@@ -99,10 +95,18 @@ func (a *API) builds(w http.ResponseWriter, r *http.Request) {
} }
scanErr := rows.Err() scanErr := rows.Err()
rows.Close() rows.Close()
if err != nil || scanErr != nil { if err == nil {
err = scanErr
}
}
buildQueue.Unlock()
if err != nil {
fail(w, 500, "database error") fail(w, 500, "database error")
return 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 { if err := a.reconcileBuilds(r.Context(), result); err != nil {
fail(w, 502, "docker image reconciliation failed") fail(w, 502, "docker image reconciliation failed")
return return
@@ -111,7 +115,7 @@ func (a *API) builds(w http.ResponseWriter, r *http.Request) {
writeJSON(w, result) writeJSON(w, result)
return return
} }
b, err := a.enqueueBuildLocked(r.Context(), u.ID, in.Version) b, err := a.enqueueBuild(r.Context(), u.ID, in.Version)
if err != nil { if err != nil {
status := 502 status := 502
if errors.Is(err, errBuildRateLimited) { 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") 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) { // lookupActiveBuild returns the active or successful record for a version; it
buildQueue.Lock() // performs no I/O and must be called with the build queue held.
defer buildQueue.Unlock() func (a *API) lookupActiveBuild(version string) (buildRecord, bool, error) {
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) {
var b buildRecord var b buildRecord
var finished, recipe, platform sql.NullString 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) 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 b.RecipeRev, b.Platform, b.Finished = recipe.String, platform.String, finished.String
if err == nil && b.Status == "success" { if errors.Is(err, sql.ErrNoRows) {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second) return b, false, nil
id, lookupErr := a.localBuildImage(ctx, b.Version, b.ImageID) }
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() cancel()
if lookupErr != nil { if lookupErr != nil {
return b, lookupErr return b, lookupErr
} }
if id != b.ImageID || id == "" { if id != "" && id == b.ImageID {
if _, err = a.s.DB.Exec("UPDATE image_builds SET status='missing',error='image no longer available' WHERE id=?", b.ID); err == nil { a.startBuildWorker()
err = sql.ErrNoRows return b, 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)
} }
if errors.Is(err, sql.ErrNoRows) { buildQueue.Unlock()
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 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)
}
if err != nil { if err != nil {
return b, err return b, err
} }
a.startBuildWorker() a.startBuildWorker()
return b, nil 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
}
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() { 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] { if buildQueue.active[a.s.DB] {
return return
} }
+9 -4
View File
@@ -26,9 +26,11 @@ func (a *API) deleteImage(w http.ResponseWriter, r *http.Request) {
fail(w, 400, "invalid image_id") fail(w, 400, "invalid image_id")
return return
} }
// Upgrades also acquire a.mu before buildQueue. Keep both through deletion. // Hold the build queue through the checks and the Docker deletion so a
a.mu.Lock() // record cannot flip to missing mid-flight. Upgrades write their journal
defer a.mu.Unlock() // 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() buildQueue.Lock()
defer buildQueue.Unlock() defer buildQueue.Unlock()
if buildQueue.active[a.s.DB] { if buildQueue.active[a.s.DB] {
@@ -43,7 +45,7 @@ func (a *API) deleteImage(w http.ResponseWriter, r *http.Request) {
return return
} }
defer tx.Rollback() 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 { if _, err = tx.ExecContext(ctx, "UPDATE images SET status=status WHERE image_id=?", id); err != nil {
fail(w, 500, "image index unavailable") fail(w, 500, "image index unavailable")
return return
@@ -132,6 +134,9 @@ func (a *API) deleteImage(w http.ResponseWriter, r *http.Request) {
fail(w, status, "docker image deletion failed") fail(w, status, "docker image deletion failed")
return 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 { if err = tx.Commit(); err != nil {
fail(w, 500, "image deleted but recording failed") fail(w, 500, "image deleted but recording failed")
return return
@@ -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)
}
}
}
@@ -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())
}
}
+44 -17
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -72,22 +73,36 @@ 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 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) { 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() buildQueue.Lock()
defer buildQueue.Unlock() generation := a.imageDeleteGeneration
if err := a.ensureBuilds(); err != nil { err := a.ensureBuilds()
return nil, err 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) var rows *sql.Rows
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 { if 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,
}
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`) (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{} result := []imageRecord{}
if err == nil {
for rows.Next() { for rows.Next() {
var im imageRecord 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 { if err = rows.Scan(&im.ImageID, &im.Version, &im.RecipeRev, &im.Platform, &im.Tag, &im.Status, &im.Created, &im.UsageCount); err != nil {
@@ -98,23 +113,29 @@ func (a *API) imageIndex(ctx context.Context) ([]imageRecord, error) {
} }
scanErr := rows.Err() scanErr := rows.Err()
rows.Close() rows.Close()
if err == nil {
err = scanErr
}
}
buildQueue.Unlock()
if err != nil { if err != nil {
return nil, err return nil, err
} }
if scanErr != nil { // Phase 2: inspect Docker without the queue lock; per-image state comes
return nil, scanErr // 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 { var local []struct {
ID string ID string
Labels map[string]string Labels map[string]string
} }
{
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 { if err := a.docker.Do(ctx, http.MethodGet, "/images/json?filters="+url.QueryEscape(string(filters)), nil, &local); err != nil {
return nil, err return nil, err
} }
known := make(map[string]bool, len(result))
for _, im := range result {
known[im.ImageID] = true
} }
for _, im := range local { for _, im := range local {
if im.ID != "" && !known[im.ID] && im.Labels["dsh-mgr.role"] == "runtime" { 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" 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) tx, err := a.s.DB.BeginTx(ctx, nil)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -179,7 +206,7 @@ func (a *API) StartImageBuilds() error {
if err := a.ensureBuilds(); err != nil { if err := a.ensureBuilds(); err != nil {
return err return err
} }
a.startBuildWorker() a.startBuildWorkerLocked()
return nil return nil
} }
+56
View File
@@ -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
}
+3 -2
View File
@@ -101,8 +101,9 @@ func (a *API) orphans(w http.ResponseWriter, r *http.Request) {
} }
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel() defer cancel()
a.mu.Lock() // checkOrphan re-verifies instance rows, upgrade journal entries and mount
defer a.mu.Unlock() // 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 { switch r.Method {
case "GET": case "GET":
filters, _ := json.Marshal(map[string][]string{"label": {"dsh-mgr.role=dsh"}}) filters, _ := json.Marshal(map[string][]string{"label": {"dsh-mgr.role=dsh"}})
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"strings" "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 { func (a *API) quotaGrowth(uid string, growth int64) error {
var u store.User var u store.User
u.ID = uid u.ID = uid
+2 -2
View File
@@ -76,8 +76,8 @@ func TestQuotaSerializedWriters(t *testing.T) {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
workspaceMu.Lock() user := a.userLocks.Acquire(i.UID)
defer workspaceMu.Unlock() defer user.Release()
if a.quotaGrowth(i.UID, 4) == nil { if a.quotaGrowth(i.UID, 4) == nil {
if err := os.WriteFile(filepath.Join(home, name), []byte("1234"), 0600); err != nil { if err := os.WriteFile(filepath.Join(home, name), []byte("1234"), 0600); err != nil {
t.Error(err) t.Error(err)
+1 -2
View File
@@ -11,9 +11,8 @@ import (
// Reconcile checks persisted identities against Docker without deleting unknown // Reconcile checks persisted identities against Docker without deleting unknown
// containers. Orphans require explicit administrator review, never blind adoption. // 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 { 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"}}) filters, _ := json.Marshal(map[string][]string{"label": {"dsh-mgr.instance"}})
var containers []struct { var containers []struct {
ID string ID string
+29 -15
View File
@@ -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) { func (a *API) createRuntime(ctx context.Context, u store.User, i store.Instance, trustedHost string) (store.Instance, error) {
a.mu.Lock() // The instance row is visible to its owner while creating, so hold the
defer a.mu.Unlock() // 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 { if _, err := a.s.GetInstance(i.ID); err != nil {
return i, errors.New("instance reservation unavailable") return i, errors.New("instance reservation unavailable")
} }
@@ -317,8 +321,8 @@ func (a *API) instance(w http.ResponseWriter, r *http.Request) {
return return
} }
if action == "" && r.Method == "DELETE" { if action == "" && r.Method == "DELETE" {
a.mu.Lock() inst := a.instanceLocks.Acquire(p[0])
defer a.mu.Unlock() defer inst.Release()
i, err = a.s.GetInstance(p[0]) i, err = a.s.GetInstance(p[0])
if err != nil || i.UID != u.ID { if err != nil || i.UID != u.ID {
fail(w, 404, "not found") 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") fail(w, 409, "instance requires recovery")
return 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") fail(w, 409, "instance files busy; retry later")
return return
} }
@@ -359,8 +366,8 @@ func (a *API) instance(w http.ResponseWriter, r *http.Request) {
fail(w, 405, "method not allowed") fail(w, 405, "method not allowed")
return return
} }
a.mu.Lock() inst := a.instanceLocks.Acquire(p[0])
defer a.mu.Unlock() defer inst.Release()
i, err = a.s.GetInstance(p[0]) i, err = a.s.GetInstance(p[0])
if err != nil || i.UID != u.ID { if err != nil || i.UID != u.ID {
fail(w, 404, "not found") 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") fail(w, 409, "instance requires recovery")
return 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") fail(w, 409, "instance files busy; retry later")
return return
} }
@@ -740,14 +750,18 @@ func (a *API) proxy(w http.ResponseWriter, r *http.Request) {
fail(w, 401, "authentication required") fail(w, 401, "authentication required")
return return
} }
a.mu.Lock() p := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/dsh/"), "/", 2)
locked := true if p[0] == "" {
fail(w, 404, "not found")
return
}
inst := a.instanceLocks.Acquire(p[0])
locked := inst
defer func() { defer func() {
if locked { if locked != nil {
a.mu.Unlock() locked.Release()
} }
}() }()
p := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/dsh/"), "/", 2)
i, err := a.s.GetInstance(p[0]) i, err := a.s.GetInstance(p[0])
if err != nil || i.UID != u.ID { if err != nil || i.UID != u.ID {
fail(w, 404, "not found") fail(w, 404, "not found")
@@ -807,8 +821,8 @@ func (a *API) proxy(w http.ResponseWriter, r *http.Request) {
return return
} }
cancel() cancel()
a.mu.Unlock() locked.Release()
locked = false locked = nil
proxy := runtimeReverseProxy(target, prefix, host, cookies) proxy := runtimeReverseProxy(target, prefix, host, cookies)
a.invalidateRejectedRuntimeCookie(proxy, i, c.State.StartedAt, host, cookies) a.invalidateRejectedRuntimeCookie(proxy, i, c.State.StartedAt, host, cookies)
if bridge { if bridge {
+39
View File
@@ -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) { func TestRuntimeCreateLimitAndPolicy(t *testing.T) {
if os.Geteuid() != 0 { if os.Geteuid() != 0 {
t.Skip("chown requires root") t.Skip("chown requires root")
+9 -6
View File
@@ -242,7 +242,7 @@ func upgradeCopy(ctx context.Context, r *os.Root, source, target string, limit i
return upgradeSync(r) 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 { func (a *API) upgradeRecoveryRequired(i store.Instance) bool {
var exists int 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 { 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") fail(w, 400, "invalid image_id")
return return
} }
a.mu.Lock() inst := a.instanceLocks.Acquire(i.ID)
defer a.mu.Unlock() defer inst.Release()
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") fail(w, 409, "instance files busy; retry later")
return return
} }
workspaceMu.Lock() user := a.userLocks.Acquire(i.UID)
defer workspaceMu.Unlock() defer user.Release()
current, err := a.s.GetInstance(i.ID) current, err := a.s.GetInstance(i.ID)
if err != nil || current.UID != i.UID { if err != nil || current.UID != i.UID {
fail(w, 404, "not found") fail(w, 404, "not found")
+94 -15
View File
@@ -55,11 +55,12 @@ func TestSlowDownloadDoesNotHoldLifecycleLock(t *testing.T) {
case <-time.After(3 * time.Second): case <-time.After(3 * time.Second):
t.Fatal("download not started") t.Fatal("download not started")
} }
if !a.mu.TryLock() { if a.instanceLocks.held() {
t.Fatal("slow network holds global lifecycle lock") t.Fatal("slow network holds instance lifecycle lock")
} }
a.readersMu.Lock()
active := a.workspaceReaders["one"] active := a.workspaceReaders["one"]
a.mu.Unlock() a.readersMu.Unlock()
if active != 1 { if active != 1 {
t.Fatalf("lease count %d", active) t.Fatalf("lease count %d", active)
} }
@@ -174,14 +175,12 @@ func TestSlowUploadDoesNotHoldLifecycleLock(t *testing.T) {
case <-time.After(3 * time.Second): case <-time.After(3 * time.Second):
t.Fatal("upload not reading body") t.Fatal("upload not reading body")
} }
if !a.mu.TryLock() { if a.instanceLocks.held() {
t.Fatal("slow upload holds global lock") t.Fatal("slow upload holds instance lock")
} }
a.mu.Unlock() if a.userLocks.held() {
if !workspaceMu.TryLock() { t.Fatal("slow upload holds user write lock")
t.Fatal("slow upload holds workspace write lock")
} }
workspaceMu.Unlock()
} }
func TestWorkspaceErrorResponseReleasesLocks(t *testing.T) { func TestWorkspaceErrorResponseReleasesLocks(t *testing.T) {
@@ -204,16 +203,96 @@ func TestWorkspaceErrorResponseReleasesLocks(t *testing.T) {
case <-time.After(3 * time.Second): case <-time.After(3 * time.Second):
t.Fatal("response not started") t.Fatal("response not started")
} }
if !workspaceMu.TryLock() { if a.userLocks.held() {
t.Fatal("error response holds workspace lock") t.Fatal("error response holds user write lock")
} }
workspaceMu.Unlock() if a.instanceLocks.held() {
if !a.mu.TryLock() { t.Fatal("error response holds instance lock")
t.Fatal("error response holds lifecycle lock")
} }
a.readersMu.Lock()
leases := len(a.workspaceReaders) leases := len(a.workspaceReaders)
a.mu.Unlock() a.readersMu.Unlock()
if leases != 0 { if leases != 0 {
t.Fatal("response retains lease") 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):
}
}
+115 -314
View File
@@ -18,7 +18,7 @@
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.3", "typescript": "~5.7.3",
"vite": "^6.1.0", "vite": "^6.1.0",
"vitest": "^3.0.5" "vitest": "^5.0.4"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -1322,117 +1322,12 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "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": { "node_modules/@vitest/spy": {
"version": "3.2.7", "version": "5.0.4",
"resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-3.2.7.tgz", "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-5.0.4.tgz",
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==",
"dev": true, "dev": true,
"license": "MIT", "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": { "funding": {
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
@@ -1494,16 +1389,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "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": { "node_modules/caniuse-lite": {
"version": "1.0.30001815", "version": "1.0.30001815",
"resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz",
@@ -1526,32 +1411,15 @@
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/chai": { "node_modules/chai": {
"version": "5.3.3", "version": "6.3.0",
"resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz", "resolved": "https://registry.npmmirror.com/chai/-/chai-6.3.0.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
"dev": true, "dev": true,
"license": "MIT", "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": { "engines": {
"node": ">=18" "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": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", "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": { "node_modules/electron-to-chromium": {
"version": "1.5.452", "version": "1.5.452",
"resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz",
@@ -1602,9 +1460,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "1.7.0", "version": "2.3.2",
"resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -1756,13 +1614,6 @@
"node": ">=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": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -1774,13 +1625,13 @@
} }
}, },
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "1.4.3",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-1.4.3.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.6.0"
} }
}, },
"node_modules/monaco-editor": { "node_modules/monaco-editor": {
@@ -1825,21 +1676,18 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/pathe": { "node_modules/obug": {
"version": "2.0.3", "version": "3.0.0",
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", "resolved": "https://registry.npmmirror.com/obug/-/obug-3.0.0.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==",
"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==",
"dev": true, "dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 14.16" "node": ">=12.20.0"
} }
}, },
"node_modules/picocolors": { "node_modules/picocolors": {
@@ -1984,13 +1832,6 @@
"semver": "bin/semver.js" "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": { "node_modules/source-map-js": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.2.tgz", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.2.tgz",
@@ -2001,53 +1842,32 @@
"node": ">=0.10.0" "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": { "node_modules/std-env": {
"version": "3.10.0", "version": "4.3.0",
"resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", "resolved": "https://registry.npmmirror.com/std-env/-/std-env-4.3.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
"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==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "6.2.2",
"resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-6.2.2.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"engines": {
"node": ">=20.0.0"
}
}, },
"node_modules/tinyexec": { "node_modules/tinyexec": {
"version": "0.3.2", "version": "1.3.1",
"resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz", "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.3.1.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"engines": {
"node": ">=18"
}
}, },
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
@@ -2066,36 +1886,6 @@
"url": "https://github.com/sponsors/SuperchupuDev" "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": { "node_modules/typescript": {
"version": "5.7.3", "version": "5.7.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.7.3.tgz", "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": { "node_modules/vitest": {
"version": "3.2.7", "version": "5.0.4",
"resolved": "https://registry.npmmirror.com/vitest/-/vitest-3.2.7.tgz", "resolved": "https://registry.npmmirror.com/vitest/-/vitest-5.0.4.tgz",
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/expect": "3.2.7", "@vitest/mocker": "5.0.4",
"@vitest/mocker": "3.2.7", "chai": "^6.3.0",
"@vitest/pretty-format": "^3.2.7", "es-module-lexer": "^2.3.2",
"@vitest/runner": "3.2.7", "expect-type": "^1.4.0",
"@vitest/snapshot": "3.2.7", "magic-string": "^1.4.3",
"@vitest/spy": "3.2.7", "obug": "^3.0.0",
"@vitest/utils": "3.2.7", "picomatch": "^4.0.7",
"chai": "^5.2.0", "std-env": "^4.3.0",
"debug": "^4.4.1", "tinybench": "^6.2.2",
"expect-type": "^1.2.1", "tinyexec": "^1.3.1",
"magic-string": "^0.30.17", "tinyglobby": "^0.2.17",
"pathe": "^2.0.3", "why-is-node-running": "3.2.1"
"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"
}, },
"bin": { "bin": {
"vitest": "vitest.mjs" "vitest": "vitest.mjs"
}, },
"engines": { "engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0" "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
}, },
"peerDependencies": { "peerDependencies": {
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@types/debug": "^4.1.12", "@opentelemetry/api": "^1.9.0",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@types/node": "^22.0.0 || >=24.0.0",
"@vitest/browser": "3.2.7", "@vitest/browser-playwright": "5.0.4",
"@vitest/ui": "3.2.7", "@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": "*", "happy-dom": "*",
"jsdom": "*" "jsdom": "*",
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@edge-runtime/vm": { "@edge-runtime/vm": {
"optional": true "optional": true
}, },
"@types/debug": { "@opentelemetry/api": {
"optional": true "optional": true
}, },
"@types/node": { "@types/node": {
"optional": true "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 "optional": true
}, },
"@vitest/ui": { "@vitest/ui": {
@@ -2309,24 +2083,51 @@
}, },
"jsdom": { "jsdom": {
"optional": true "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": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "3.2.1",
"resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": { "bin": {
"why-is-node-running": "cli.js" "why-is-node-running": "cli.js"
}, },
"engines": { "engines": {
"node": ">=8" "node": ">=20.11"
} }
}, },
"node_modules/yallist": { "node_modules/yallist": {
+1 -1
View File
@@ -20,6 +20,6 @@
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.3", "typescript": "~5.7.3",
"vite": "^6.1.0", "vite": "^6.1.0",
"vitest": "^3.0.5" "vitest": "^5.0.4"
} }
} }
+4 -3
View File
@@ -1,13 +1,14 @@
import { useEffect, useRef, useState, type FormEvent } from 'react'; import { useEffect, useRef, useState, type FormEvent } from 'react';
import { api, type Image, type Instance } from './api'; 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<void>}) { export function CreateInstance({close, refresh}: {close: () => void; refresh: () => Promise<void>}) {
const images = useResource(api.images), action = useAction(); const images = useResource(api.images), action = useAction();
const [mode, setMode] = useState('local'), [progress, setProgress] = useState(''); const [mode, setMode] = useState('local'), [progress, setProgress] = useState('');
const [version, setVersion] = useState(''); const [version, setVersion] = useState('');
const [pollError, setPollError] = useState('');
const [intents, setIntents] = useState<{id:string; name:string; version:string; status:string; error:string}[]>([]); const [intents, setIntents] = useState<{id:string; name:string; version:string; status:string; error:string}[]>([]);
useEffect(() => {let live=true; const load=()=>api.request<typeof intents>('/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<typeof intents>('/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); const active = useRef(true);
useEffect(() => {active.current = true; return () => {active.current = false;};}, []); useEffect(() => {active.current = true; return () => {active.current = false;};}, []);
const available = images.data?.filter(i => !i.status || i.status === 'available') || []; 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(''); if (active.current) setProgress('');
} }
return <form className="inline-form" onSubmit={submit}><h3>创建独立实例</h3>{action.feedback}<p className="callout">确认后服务器持久预留一个名额(每用户最多两个)。刷新或关闭不会取消自动创建;失败释放名额,不回退其他版本。取消等待不会取消共享构建,创建开始后不可撤回。</p><fieldset disabled={action.busy}><label>实例名称<input name="name" required maxLength={64}/></label><label>创建方式<select value={mode} onChange={e => {setMode(e.target.value); setVersion('');}}><option value="local">选择本地镜像</option><option value="latest">构建最新版并创建</option></select></label>{mode === 'local' && <><ResourceState resource={images}/><label>本地镜像<select name="image" required defaultValue=""><option value="" disabled>选择已构建镜像</option>{available.map(i => <option key={i.image_id || i.id} value={i.image_id || i.id}>{i.version} · {i.tag || i.image_id || i.id}</option>)}</select></label>{!images.loading && !available.length && <p>暂无可用镜像,可选择构建最新版并创建。</p>}</>}{mode === 'latest' && version && <p>将锁定精确版本 <strong>{version}</strong>,确认后预留名额并构建创建。</p>}<button className="primary" disabled={mode === 'local' && !available.length}>{mode === 'local' ? '确认创建' : version ? '确认版本并预留创建' : '查询最新版'}</button></fieldset>{progress && <p role="status">{progress}</p>}<ul>{intents.map(i => <li key={i.id}>{i.name} · {i.version} · {i.status} {i.error}{i.status === 'waiting' && <button type="button" disabled={action.busy} onClick={() => void action.run(async () => {await api.request('/intents/' + i.id, 'DELETE'); setIntents(await api.request<typeof intents>('/intents')); await refresh();})}>取消等待并释放名额</button>}{i.status === 'success' && <button type="button" onClick={() => void refresh()}>刷新实例列表</button>}</li>)}</ul><button type="button" onClick={close}>关闭(保留已提交意图)</button></form>; return <form className="inline-form" onSubmit={submit}><h3>创建独立实例</h3>{action.feedback}<p className="callout">确认后服务器持久预留一个名额(每用户最多两个)。刷新或关闭不会取消自动创建;失败释放名额,不回退其他版本。取消等待不会取消共享构建,创建开始后不可撤回。</p><fieldset disabled={action.busy}><label>实例名称<input name="name" required maxLength={64}/></label><label>创建方式<select value={mode} onChange={e => {setMode(e.target.value); setVersion('');}}><option value="local">选择本地镜像</option><option value="latest">构建最新版并创建</option></select></label>{mode === 'local' && <><ResourceState resource={images}/><label>本地镜像<select name="image" required defaultValue=""><option value="" disabled>选择已构建镜像</option>{available.map(i => <option key={i.image_id || i.id} value={i.image_id || i.id}>{i.version} · {i.tag || i.image_id || i.id}</option>)}</select></label>{!images.loading && !available.length && <p>暂无可用镜像,可选择构建最新版并创建。</p>}</>}{mode === 'latest' && version && <p>将锁定精确版本 <strong>{version}</strong>,确认后预留名额并构建创建。</p>}<button className="primary" disabled={mode === 'local' && !available.length}>{mode === 'local' ? '确认创建' : version ? '确认版本并预留创建' : '查询最新版'}</button></fieldset>{progress && <p role="status">{progress}</p>}{pollError && <p className="muted" role="alert">意图刷新失败:{pollError}</p>}<ul>{intents.map(i => <li key={i.id}>{i.name} · {i.version} · {i.status} {i.error}{i.status === 'waiting' && <button type="button" disabled={action.busy} onClick={() => void action.run(async () => {await api.request('/intents/' + i.id, 'DELETE'); setIntents(await api.request<typeof intents>('/intents')); await refresh();})}>取消等待并释放名额</button>}{i.status === 'success' && <button type="button" onClick={() => void refresh()}>刷新实例列表</button>}</li>)}</ul><button type="button" disabled={action.busy} onClick={close}>关闭(保留已提交意图)</button></form>;
} }
+1 -1
View File
@@ -6,6 +6,6 @@ export function Orphans() {
<p>仅列出已停止、归属和挂载核验通过且无实例或升级引用的容器。回收保留所有挂载数据,不删除网络。运行中及来源不明的容器不会列入。</p> <p>仅列出已停止、归属和挂载核验通过且无实例或升级引用的容器。回收保留所有挂载数据,不删除网络。运行中及来源不明的容器不会列入。</p>
{action.feedback}<ResourceState resource={list}/> {action.feedback}<ResourceState resource={list}/>
{list.data?.length===0 && <p>没有可安全回收的容器。</p>} {list.data?.length===0 && <p>没有可安全回收的容器。</p>}
{list.data?.map(id=><div key={id}><code>{id}</code><button disabled={action.busy} onClick={()=>action.run(async()=>{if(window.prompt('请输入完整容器 ID 确认回收(数据保留)')!==id)throw new Error('确认不匹配,未执行');await api.request('/orphans?id='+id+'&confirm='+id,'DELETE');await list.refresh();},'容器已回收,数据已保留','确认回收此停止的孤儿容器?')}>回收</button></div>)} {list.data?.map(id=><div key={id}><code>{id}</code><button disabled={action.busy} onClick={()=>action.run(async()=>{if(window.prompt('请输入完整容器 ID 确认回收(数据保留)')!==id)throw new Error('确认不匹配,未执行');await api.request('/orphans?id='+encodeURIComponent(id)+'&confirm='+encodeURIComponent(id),'DELETE');await list.refresh();},'容器已回收,数据已保留','确认回收此停止的孤儿容器?')}>回收</button></div>)}
</Panel>; </Panel>;
} }