From 3bf197206a26bf02758b68e358ae924d2985edc5 Mon Sep 17 00:00:00 2001 From: m1saka Date: Wed, 14 Oct 2026 19:37:52 +0800 Subject: [PATCH] fix: prune missing image indexes and align instance creation payload --- API.md | 2 +- internal/api/images_index_concurrency_test.go | 15 +-- internal/api/images_policy.go | 21 +++- internal/api/images_policy_test.go | 105 +++++++++++++++++- web/src/CreateInstance.tsx | 2 +- web/src/Images.test.tsx | 4 +- 6 files changed, 125 insertions(+), 24 deletions(-) diff --git a/API.md b/API.md index 1ba8a9d..5e7757d 100644 --- a/API.md +++ b/API.md @@ -49,7 +49,7 @@ GET /api/orphans 返回可回收的完整容器ID数组。DELETE /api/orphans?id - GET /api/releases/latest(仅管理员;获取并校验配置的 Git 发布分支,返回 release_id、version、commit、repository、branch、recipe_rev、platforms、compatible、reason,以及可选的 build。检查会下载构建文件,但不执行 Dockerfile;未知部署协议或平台不允许构建) - POST /api/releases/build {commit}(仅管理员、同源和 CSRF;提交检查时看到的精确 commit,分支已变化返回 409,需重新检查。返回 202 构建任务;不自动升级实例) - Git 构建任务附带 release_id、source_commit、source_repository 来源信息;发布身份独立于 DSH 版本及部署布局,允许同一 DSH 版本构建新的脚本修订。可信发布通过现有镜像身份和启动验证后可准入,显式禁用仍优先。新构建仅允许管理员通过 Git 发布接口提交;main 不再包含内置 runtime 构建资产。 -- GET /api/images(每项含 admission、admission_reason、selectable;只有 status=available 且 selectable=true 才可新建/升级) +- GET /api/images(仅返回 Docker 本地实际存在的镜像;每项含 admission、admission_reason、selectable;只有 status=available 且 selectable=true 才可新建/升级。inspect 确认 404 后原子删除镜像索引行并将成功构建记录标为 missing,保留构建历史、显式准入策略和实例;missing 历史不会重建索引。Docker 列表或检查失败时请求失败,不删除索引行) - POST /api/images/admission {image_id,state,evidence}(管理员、同源和CSRF;state为recommended/compatible/existing_only/blocked,evidence必填。正向准入要求精确镜像身份、成功验证记录、受支持挂载布局与平台;策略与审计持久化。单镜像准入不授权新构建) - DELETE /api/images?image_id=sha256:...(管理员;保护实例、活动任务、升级及全部Docker容器引用,不force) - GET /api/builds?limit=20&offset=0(摘要数组,不含日志正文;limit默认20、最大100,offset非负;X-Has-More响应头表示后续是否有记录) diff --git a/internal/api/images_index_concurrency_test.go b/internal/api/images_index_concurrency_test.go index 2548c6e..37f79e2 100644 --- a/internal/api/images_index_concurrency_test.go +++ b/internal/api/images_index_concurrency_test.go @@ -93,22 +93,19 @@ func TestImageIndexDoesNotUndoConcurrentDelete(t *testing.T) { 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) + if matches != 0 { + t.Errorf("returned %d records for deleted image, want 0", 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 { + var count int + if err := a.s.DB.QueryRow("SELECT count(*) FROM images WHERE image_id=?", id).Scan(&count); err != nil { t.Fatal(err) } - if status != "missing" { - t.Errorf("persisted deleted image as %s", status) + if count != 0 { + t.Errorf("persisted %d rows for deleted image", count) } }) } diff --git a/internal/api/images_policy.go b/internal/api/images_policy.go index 05413d3..6097cf4 100644 --- a/internal/api/images_policy.go +++ b/internal/api/images_policy.go @@ -92,7 +92,7 @@ func (a *API) imageIndexSnapshot(ctx context.Context) ([]imageRecord, error) { err := a.ensureBuilds() if err == nil { _, err = a.s.DB.ExecContext(ctx, `INSERT OR IGNORE INTO images(image_id,version,recipe_rev,platform,tag,status,built_at) - SELECT image_id,version,recipe_rev,platform,'','missing',COALESCE(finished_at,created_at) FROM image_builds WHERE status IN ('success','missing') AND image_id<>'' ORDER BY created_at DESC,id DESC`) + SELECT image_id,version,recipe_rev,platform,'','missing',COALESCE(finished_at,created_at) FROM image_builds WHERE status='success' AND image_id<>'' ORDER BY created_at DESC,id DESC`) } var rows *sql.Rows if err == nil { @@ -184,18 +184,27 @@ func (a *API) imageIndexSnapshot(ctx context.Context) ([]imageRecord, error) { return nil, err } defer tx.Rollback() + present := make([]imageRecord, 0, len(result)) for _, im := range result { - if _, err = tx.ExecContext(ctx, `INSERT INTO images(image_id,version,recipe_rev,platform,tag,status,built_at,usage_count) VALUES(?,?,?,?,?,?,?,?) - ON CONFLICT(image_id) DO UPDATE SET version=excluded.version,recipe_rev=excluded.recipe_rev,platform=excluded.platform,tag=excluded.tag,status=excluded.status,built_at=excluded.built_at,usage_count=excluded.usage_count`, im.ImageID, im.Version, im.RecipeRev, im.Platform, im.Tag, im.Status, im.Created, im.UsageCount); err != nil { - return nil, err - } if im.Status == "missing" { if _, err = tx.ExecContext(ctx, "UPDATE image_builds SET status='missing',error='image no longer available or template identity is unknown' WHERE image_id=? AND status='success'", im.ImageID); err != nil { return nil, err } + if _, err = tx.ExecContext(ctx, "DELETE FROM images WHERE image_id=?", im.ImageID); err != nil { + return nil, err + } + continue } + if _, err = tx.ExecContext(ctx, `INSERT INTO images(image_id,version,recipe_rev,platform,tag,status,built_at,usage_count) VALUES(?,?,?,?,?,?,?,?) + ON CONFLICT(image_id) DO UPDATE SET version=excluded.version,recipe_rev=excluded.recipe_rev,platform=excluded.platform,tag=excluded.tag,status=excluded.status,built_at=excluded.built_at,usage_count=excluded.usage_count`, im.ImageID, im.Version, im.RecipeRev, im.Platform, im.Tag, im.Status, im.Created, im.UsageCount); err != nil { + return nil, err + } + present = append(present, im) } - return result, tx.Commit() + if err = tx.Commit(); err != nil { + return nil, err + } + return present, nil } func (a *API) StartImageBuilds() error { diff --git a/internal/api/images_policy_test.go b/internal/api/images_policy_test.go index c2ae5a0..723aa57 100644 --- a/internal/api/images_policy_test.go +++ b/internal/api/images_policy_test.go @@ -173,7 +173,7 @@ func TestImageIndexLocalInventory(t *testing.T) { } deleted.Store(true) w = imageRequest(a.images, sid, "GET", "") - if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil || w.Code != 200 || len(images) != 1 || images[0].Status != "missing" { + if err := json.Unmarshal(w.Body.Bytes(), &images); err != nil || w.Code != 200 || len(images) != 0 { t.Fatal(w.Body.String(), err) } }) @@ -226,17 +226,112 @@ func TestImageIndexUsageMissingAndDockerFailure(t *testing.T) { t.Fatal(status, err) } mode.Store(2) - check("missing", 2) - if _, err := a.s.DB.Exec("DELETE FROM instances WHERE id='two'"); err != nil { - t.Fatal(err) + for refresh := 0; refresh < 3; refresh++ { + w := imageRequest(a.images, sid, "GET", "") + if w.Code != 200 || strings.TrimSpace(w.Body.String()) != "[]" { + t.Fatal(w.Code, w.Body.String()) + } + var count int + if err := a.s.DB.QueryRow("SELECT count(*) FROM images").Scan(&count); err != nil || count != 0 { + t.Fatal(count, err) + } + } + var count int + if err := a.s.DB.QueryRow("SELECT count(*) FROM instances WHERE image_id='image'").Scan(&count); err != nil || count != 2 { + t.Fatal(count, err) + } + if err := a.s.DB.QueryRow("SELECT status FROM image_builds WHERE image_id='image'").Scan(&status); err != nil || status != "missing" { + t.Fatal(status, err) } - check("missing", 1) verified, err := a.verifiedImages(context.Background()) if err != nil || len(verified) != 0 { t.Fatal(verified, err) } } +func TestImageIndexFailurePreservesRows(t *testing.T) { + for _, mode := range []string{"list", "inspect", "rollback"} { + t.Run(mode, func(t *testing.T) { + a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1.47/images/json" { + if mode == "list" { + w.WriteHeader(500) + } else { + writeJSON(w, []any{}) + } + return + } + if mode == "inspect" && strings.Contains(r.URL.Path, "second") { + w.WriteHeader(500) + return + } + w.WriteHeader(404) + }) + seedVerifiedImage(t, a, "first", "1.0.0") + seedVerifiedImage(t, a, "second", "2.0.0") + if _, err := a.s.DB.Exec("INSERT INTO images(image_id,version,status,built_at) VALUES('first','1.0.0','available','2'),('second','2.0.0','available','1')"); err != nil { + t.Fatal(err) + } + if mode == "rollback" { + if _, err := a.s.DB.Exec("CREATE TRIGGER deny_second_delete BEFORE DELETE ON images WHEN OLD.image_id='second' BEGIN SELECT RAISE(FAIL,'unavailable'); END"); err != nil { + t.Fatal(err) + } + } + if images, err := a.imageIndex(context.Background()); err == nil || images != nil { + t.Fatalf("images=%v err=%v", images, err) + } + for _, query := range []string{"SELECT count(*) FROM images WHERE status='available'", "SELECT count(*) FROM image_builds WHERE status='success'"} { + var count int + if err := a.s.DB.QueryRow(query).Scan(&count); err != nil || count != 2 { + t.Fatal(query, count, err) + } + } + }) + } +} + +func TestImageIndexMissingHistoryAndBlockedReimport(t *testing.T) { + var present atomic.Bool + var inspections atomic.Int32 + a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1.47/images/json" { + if present.Load() { + writeJSON(w, []any{map[string]any{"Id": "image", "Labels": map[string]string{"dsh-mgr.role": "runtime"}}}) + } else { + writeJSON(w, []any{}) + } + return + } + inspections.Add(1) + if !present.Load() { + w.WriteHeader(404) + return + } + writeImageInspect(w, "image", "0.2.0-rc.1") + }) + seedVerifiedImage(t, a, "image", "0.2.0-rc.1") + if _, err := a.s.DB.Exec("UPDATE image_admissions SET state='blocked',evidence='unsafe'"); err != nil { + t.Fatal(err) + } + for refresh := 0; refresh < 3; refresh++ { + if images, err := a.imageIndex(context.Background()); err != nil || len(images) != 0 { + t.Fatal(images, err) + } + } + if inspections.Load() != 1 { + t.Fatalf("missing history caused %d inspections", inspections.Load()) + } + present.Store(true) + images, err := a.imageIndex(context.Background()) + if err != nil || len(images) != 1 || images[0].Status != "blocked" || images[0].Selectable || images[0].AdmissionReason != "unsafe" { + t.Fatal(images, err) + } + var status string + if err := a.s.DB.QueryRow("SELECT status FROM image_builds WHERE image_id='image'").Scan(&status); err != nil || status != "missing" { + t.Fatal(status, err) + } +} + func TestBuildDiskPolicy(t *testing.T) { a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected build") }) if err := a.checkBuildDisk(context.Background()); err != nil { diff --git a/web/src/CreateInstance.tsx b/web/src/CreateInstance.tsx index 6a466ad..33d94dd 100644 --- a/web/src/CreateInstance.tsx +++ b/web/src/CreateInstance.tsx @@ -16,7 +16,7 @@ export function CreateInstance({close, refresh}: {close: () => void; refresh: () if (!active.current) return; if (!image) throw new Error('请选择可用的本地镜像。'); setProgress('正在创建实例,请勿重复提交…'); - const created = await api.request('/instances', 'POST', {name, image_id:image.image_id || image.id, version:image.version}); + const created = await api.request('/instances', 'POST', {name, image_id:image.image_id || image.id}); if (!created?.id) throw new Error('未收到实例确认,请刷新实例列表核实,勿重复提交。'); if (active.current) {await refresh(); close();} }); diff --git a/web/src/Images.test.tsx b/web/src/Images.test.tsx index 4e8fb3b..baaab3f 100644 --- a/web/src/Images.test.tsx +++ b/web/src/Images.test.tsx @@ -50,7 +50,7 @@ it('requires evidence and confirmation; cancel sends no admission mutation', () form.props.onSubmit({preventDefault:vi.fn()}); expect(window.confirm).toHaveBeenCalledOnce(); expect(request).not.toHaveBeenCalled(); }); -it('creates only the selected verified exact image without build or intent requests', async () => { +it('creates with only name and exact image ID, leaving version resolution to the server', async () => { const request = vi.spyOn(api, 'request').mockResolvedValue({id:'created'}); vi.stubGlobal('FormData', class {get(key: string) {return key === 'image' ? 'ok' : 'name';}}); const close = vi.fn(), refresh = vi.fn(async () => {}); @@ -58,7 +58,7 @@ it('creates only the selected verified exact image without build or intent reque expect(nodes(form, n => n.type === 'select')).toHaveLength(1); expect(nodes(form, n => n.type === 'option' && n.props.value === 'exp')).toHaveLength(0); await form.props.onSubmit({preventDefault:vi.fn(), currentTarget:{}}); - expect(request.mock.calls).toEqual([['/instances', 'POST', {name:'name', image_id:'ok', version:'1.2.3'}]]); + expect(request.mock.calls).toEqual([['/instances', 'POST', {name:'name', image_id:'ok'}]]); expect(refresh).toHaveBeenCalledOnce(); expect(close).toHaveBeenCalledOnce(); }); it('directs users to an administrator and disables creation when no verified image exists', async () => {