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:
@@ -10,3 +10,4 @@ output
|
|||||||
**/*.tsbuildinfo
|
**/*.tsbuildinfo
|
||||||
.playwright-cli
|
.playwright-cli
|
||||||
**/*.sqlite*
|
**/*.sqlite*
|
||||||
|
image/*.test.mjs
|
||||||
|
|||||||
+8
-2
@@ -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=
|
||||||
|
|||||||
@@ -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
@@ -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。
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+15
-12
@@ -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 {
|
|
||||||
fail(w, 409, "instance files busy; retry later")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
workspaceMu.Lock()
|
a.readersMu.Lock()
|
||||||
defer workspaceMu.Unlock()
|
readers := a.workspaceReaders[i.ID]
|
||||||
|
a.readersMu.Unlock()
|
||||||
|
if readers > 0 {
|
||||||
|
fail(w, 409, "instance files busy; retry later")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := a.userLocks.Acquire(i.UID)
|
||||||
|
defer user.Release()
|
||||||
}
|
}
|
||||||
current, e := a.s.GetInstance(i.ID)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-49
@@ -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()
|
||||||
a.workspaceReaders[instanceID]--
|
if a.workspaceReaders != nil {
|
||||||
if a.workspaceReaders[instanceID] == 0 {
|
a.workspaceReaders[instanceID]--
|
||||||
delete(a.workspaceReaders, instanceID)
|
if a.workspaceReaders[instanceID] == 0 {
|
||||||
|
delete(a.workspaceReaders, instanceID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
a.mu.Unlock()
|
a.readersMu.Unlock()
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
workspaceMu.Lock()
|
user := a.userLocks.Acquire(u.ID)
|
||||||
root, err := a.openWorkspace(u, instanceID)
|
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})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-54
@@ -75,34 +75,38 @@ 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()
|
||||||
|
err := a.ensureBuilds()
|
||||||
|
var rows *sql.Rows
|
||||||
|
if err == nil {
|
||||||
|
rows, err = a.s.DB.Query("SELECT id,version,status,logs,error,image_id,created_at,requested_by,recipe_rev,platform,finished_at FROM image_builds ORDER BY created_at DESC,id DESC")
|
||||||
|
}
|
||||||
|
result := []buildRecord{}
|
||||||
|
if err == nil {
|
||||||
|
for rows.Next() {
|
||||||
|
var b buildRecord
|
||||||
|
var finished, recipe, platform sql.NullString
|
||||||
|
if err = rows.Scan(&b.ID, &b.Version, &b.Status, &b.Logs, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &recipe, &platform, &finished); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b.RecipeRev, b.Platform, b.Finished = recipe.String, platform.String, finished.String
|
||||||
|
result = append(result, b)
|
||||||
|
}
|
||||||
|
scanErr := rows.Err()
|
||||||
|
rows.Close()
|
||||||
|
if err == nil {
|
||||||
|
err = scanErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildQueue.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(w, 500, "database error")
|
fail(w, 500, "database error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result := []buildRecord{}
|
// Docker image reconciliation is I/O-bound and updates rows with
|
||||||
for rows.Next() {
|
// compare-and-set, so it must not hold the queue lock that instance
|
||||||
var b buildRecord
|
// creation and upgrades also need.
|
||||||
var finished, recipe, platform sql.NullString
|
|
||||||
if err = rows.Scan(&b.ID, &b.Version, &b.Status, &b.Logs, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &recipe, &platform, &finished); err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
b.RecipeRev, b.Platform, b.Finished = recipe.String, platform.String, finished.String
|
|
||||||
result = append(result, b)
|
|
||||||
}
|
|
||||||
scanErr := rows.Err()
|
|
||||||
rows.Close()
|
|
||||||
if err != nil || scanErr != nil {
|
|
||||||
fail(w, 500, "database error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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 errors.Is(err, sql.ErrNoRows) {
|
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 {
|
||||||
var count int
|
// Another request may have queued a replacement while the image
|
||||||
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 {
|
// was being verified; honor it instead of inserting a duplicate.
|
||||||
|
b, found, err = a.lookupActiveBuild(version)
|
||||||
|
}
|
||||||
|
if err == nil && !found {
|
||||||
|
b, err = a.insertBuild(uid, version)
|
||||||
|
}
|
||||||
|
buildQueue.Unlock()
|
||||||
|
if err != nil {
|
||||||
return b, err
|
return b, err
|
||||||
}
|
}
|
||||||
if count >= 3 {
|
a.startBuildWorker()
|
||||||
return b, errBuildRateLimited
|
return b, nil
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
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 {
|
if err != nil {
|
||||||
return b, err
|
return b, err
|
||||||
}
|
}
|
||||||
a.startBuildWorker()
|
if found {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
b, err = a.insertBuild(uid, version)
|
||||||
|
if err != nil {
|
||||||
|
return b, err
|
||||||
|
}
|
||||||
|
a.startBuildWorkerLocked()
|
||||||
return b, nil
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -72,49 +73,69 @@ func (a *API) imageMatches(ctx context.Context, id, version string) (bool, error
|
|||||||
return im.ID == id && im.platform() == imagePlatform && im.Config.Labels["dsh-mgr.role"] == "runtime" && im.Config.Labels["dsh-mgr.version"] == version && im.Config.Labels["dsh-mgr.recipe"] == imageRecipeRev, nil
|
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{}
|
||||||
for rows.Next() {
|
if err == nil {
|
||||||
var im imageRecord
|
for rows.Next() {
|
||||||
if err = rows.Scan(&im.ImageID, &im.Version, &im.RecipeRev, &im.Platform, &im.Tag, &im.Status, &im.Created, &im.UsageCount); err != nil {
|
var im imageRecord
|
||||||
break
|
if err = rows.Scan(&im.ImageID, &im.Version, &im.RecipeRev, &im.Platform, &im.Tag, &im.Status, &im.Created, &im.UsageCount); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
im.ID = im.ImageID
|
||||||
|
result = append(result, im)
|
||||||
|
}
|
||||||
|
scanErr := rows.Err()
|
||||||
|
rows.Close()
|
||||||
|
if err == nil {
|
||||||
|
err = scanErr
|
||||||
}
|
}
|
||||||
im.ID = im.ImageID
|
|
||||||
result = append(result, im)
|
|
||||||
}
|
}
|
||||||
scanErr := rows.Err()
|
buildQueue.Unlock()
|
||||||
rows.Close()
|
|
||||||
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
|
||||||
}
|
}
|
||||||
if err := a.docker.Do(ctx, http.MethodGet, "/images/json?filters="+url.QueryEscape(string(filters)), nil, &local); err != nil {
|
{
|
||||||
return nil, err
|
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 {
|
||||||
known := make(map[string]bool, len(result))
|
return nil, err
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"}})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Generated
+115
-314
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user