Compare commits

..
2 Commits
7 changed files with 126 additions and 24 deletions
+1 -1
View File
@@ -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;未知部署协议或平台不允许构建) - 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 构建任务;不自动升级实例) - POST /api/releases/build {commit}(仅管理员、同源和 CSRF;提交检查时看到的精确 commit,分支已变化返回 409,需重新检查。返回 202 构建任务;不自动升级实例)
- Git 构建任务附带 release_id、source_commit、source_repository 来源信息;发布身份独立于 DSH 版本及部署布局,允许同一 DSH 版本构建新的脚本修订。可信发布通过现有镜像身份和启动验证后可准入,显式禁用仍优先。新构建仅允许管理员通过 Git 发布接口提交;main 不再包含内置 runtime 构建资产。 - 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必填。正向准入要求精确镜像身份、成功验证记录、受支持挂载布局与平台;策略与审计持久化。单镜像准入不授权新构建) - POST /api/images/admission {image_id,state,evidence}(管理员、同源和CSRF;state为recommended/compatible/existing_only/blocked,evidence必填。正向准入要求精确镜像身份、成功验证记录、受支持挂载布局与平台;策略与审计持久化。单镜像准入不授权新构建)
- DELETE /api/images?image_id=sha256:...(管理员;保护实例、活动任务、升级及全部Docker容器引用,不force) - DELETE /api/images?image_id=sha256:...(管理员;保护实例、活动任务、升级及全部Docker容器引用,不force)
- GET /api/builds?limit=20&offset=0(摘要数组,不含日志正文;limit默认20、最大100,offset非负;X-Has-More响应头表示后续是否有记录) - GET /api/builds?limit=20&offset=0(摘要数组,不含日志正文;limit默认20、最大100,offset非负;X-Has-More响应头表示后续是否有记录)
@@ -93,22 +93,19 @@ func TestImageIndexDoesNotUndoConcurrentDelete(t *testing.T) {
if im.ImageID == id { if im.ImageID == id {
matches++ matches++
} }
if im.ImageID == id && im.Status != "missing" {
t.Errorf("returned deleted image as %s", im.Status)
} }
} if matches != 0 {
if matches != 1 { t.Errorf("returned %d records for deleted image, want 0", matches)
t.Errorf("returned %d records for deleted image, want 1", matches)
} }
case <-ctx.Done(): case <-ctx.Done():
t.Fatal("index did not finish") t.Fatal("index did not finish")
} }
var status string var count int
if err := a.s.DB.QueryRow("SELECT status FROM images WHERE image_id=?", id).Scan(&status); err != nil { if err := a.s.DB.QueryRow("SELECT count(*) FROM images WHERE image_id=?", id).Scan(&count); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if status != "missing" { if count != 0 {
t.Errorf("persisted deleted image as %s", status) t.Errorf("persisted %d rows for deleted image", count)
} }
}) })
} }
+15 -6
View File
@@ -92,7 +92,7 @@ func (a *API) imageIndexSnapshot(ctx context.Context) ([]imageRecord, error) {
err := a.ensureBuilds() err := a.ensureBuilds()
if err == nil { if err == nil {
_, err = a.s.DB.ExecContext(ctx, `INSERT OR IGNORE INTO images(image_id,version,recipe_rev,platform,tag,status,built_at) _, 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 var rows *sql.Rows
if err == nil { if err == nil {
@@ -184,18 +184,27 @@ func (a *API) imageIndexSnapshot(ctx context.Context) ([]imageRecord, error) {
return nil, err return nil, err
} }
defer tx.Rollback() defer tx.Rollback()
present := make([]imageRecord, 0, len(result))
for _, im := range 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 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 { 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 return nil, err
} }
if _, err = tx.ExecContext(ctx, "DELETE FROM images WHERE image_id=?", im.ImageID); err != nil {
return nil, err
} }
continue
} }
return result, tx.Commit() 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)
}
if err = tx.Commit(); err != nil {
return nil, err
}
return present, nil
} }
func (a *API) StartImageBuilds() error { func (a *API) StartImageBuilds() error {
+100 -5
View File
@@ -173,7 +173,7 @@ func TestImageIndexLocalInventory(t *testing.T) {
} }
deleted.Store(true) deleted.Store(true)
w = imageRequest(a.images, sid, "GET", "") 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) t.Fatal(w.Body.String(), err)
} }
}) })
@@ -226,17 +226,112 @@ func TestImageIndexUsageMissingAndDockerFailure(t *testing.T) {
t.Fatal(status, err) t.Fatal(status, err)
} }
mode.Store(2) mode.Store(2)
check("missing", 2) for refresh := 0; refresh < 3; refresh++ {
if _, err := a.s.DB.Exec("DELETE FROM instances WHERE id='two'"); err != nil { w := imageRequest(a.images, sid, "GET", "")
t.Fatal(err) 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()) verified, err := a.verifiedImages(context.Background())
if err != nil || len(verified) != 0 { if err != nil || len(verified) != 0 {
t.Fatal(verified, err) 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) { func TestBuildDiskPolicy(t *testing.T) {
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected build") }) a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected build") })
if err := a.checkBuildDisk(context.Background()); err != nil { if err := a.checkBuildDisk(context.Background()); err != nil {
+1
View File
@@ -1,4 +1,5 @@
node_modules/ node_modules/
.vite/
dist/ dist/
*.tsbuildinfo *.tsbuildinfo
.playwright-cli/ .playwright-cli/
+1 -1
View File
@@ -16,7 +16,7 @@ export function CreateInstance({close, refresh}: {close: () => void; refresh: ()
if (!active.current) return; if (!active.current) return;
if (!image) throw new Error('请选择可用的本地镜像。'); if (!image) throw new Error('请选择可用的本地镜像。');
setProgress('正在创建实例,请勿重复提交…'); setProgress('正在创建实例,请勿重复提交…');
const created = await api.request<Instance>('/instances', 'POST', {name, image_id:image.image_id || image.id, version:image.version}); const created = await api.request<Instance>('/instances', 'POST', {name, image_id:image.image_id || image.id});
if (!created?.id) throw new Error('未收到实例确认,请刷新实例列表核实,勿重复提交。'); if (!created?.id) throw new Error('未收到实例确认,请刷新实例列表核实,勿重复提交。');
if (active.current) {await refresh(); close();} if (active.current) {await refresh(); close();}
}); });
+2 -2
View File
@@ -50,7 +50,7 @@ it('requires evidence and confirmation; cancel sends no admission mutation', ()
form.props.onSubmit({preventDefault:vi.fn()}); form.props.onSubmit({preventDefault:vi.fn()});
expect(window.confirm).toHaveBeenCalledOnce(); expect(request).not.toHaveBeenCalled(); 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'}); const request = vi.spyOn(api, 'request').mockResolvedValue({id:'created'});
vi.stubGlobal('FormData', class {get(key: string) {return key === 'image' ? 'ok' : 'name';}}); vi.stubGlobal('FormData', class {get(key: string) {return key === 'image' ? 'ok' : 'name';}});
const close = vi.fn(), refresh = vi.fn(async () => {}); 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 === 'select')).toHaveLength(1);
expect(nodes(form, n => n.type === 'option' && n.props.value === 'exp')).toHaveLength(0); expect(nodes(form, n => n.type === 'option' && n.props.value === 'exp')).toHaveLength(0);
await form.props.onSubmit({preventDefault:vi.fn(), currentTarget:{}}); 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(); expect(refresh).toHaveBeenCalledOnce(); expect(close).toHaveBeenCalledOnce();
}); });
it('directs users to an administrator and disables creation when no verified image exists', async () => { it('directs users to an administrator and disables creation when no verified image exists', async () => {