fix(panel): 构建记录改由服务端分页列表提供,任何浏览器与管理员都能看到并取消进行中的构建,列表刷新失败保留原行并提示
This commit is contained in:
16 files changed
+711
-253
No files matched your search
@@ -604,6 +604,7 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
||||
// is build-time RCE against the cluster, so submission requires the admin
|
||||
// Zero-Trust path, not merely an authenticated session.
|
||||
{Method: "POST", Pattern: "/api/v1/images/build", Admin: true, h: a.handleBuildImage},
|
||||
{Method: "GET", Pattern: "/api/v1/images/build", Admin: true, h: a.handleListBuilds},
|
||||
{Method: "GET", Pattern: "/api/v1/images/build/{id}", Admin: true, h: a.handleGetBuild},
|
||||
{Method: "GET", Pattern: "/api/v1/images/build/{id}/logs", Admin: true, h: a.handleBuildLogs},
|
||||
{Method: "POST", Pattern: "/api/v1/images/build/{id}/cancel", Admin: true, h: a.handleCancelBuild},
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"felis.lolicon.best/internal/build"
|
||||
"felis.lolicon.best/internal/imagepin"
|
||||
@@ -25,6 +26,8 @@ type ImageBuilder interface {
|
||||
// GET doubles as the reconcile tick (idempotent on terminal builds).
|
||||
Sync(ctx context.Context, id string) (*build.Build, error)
|
||||
Cancel(ctx context.Context, id string) (*build.Build, error)
|
||||
// ListBuilds pages the build history, newest first, with the match total.
|
||||
ListBuilds(ctx context.Context, opts build.ListOpts) ([]build.Build, int, error)
|
||||
ListImages(ctx context.Context) ([]build.Image, error)
|
||||
AddExternalImage(ctx context.Context, imageRef, addedBy string) (*build.Image, error)
|
||||
RemoveImage(ctx context.Context, imageRef string) error
|
||||
@@ -78,6 +81,30 @@ func (a *API) handleBuildImage(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusAccepted, bld)
|
||||
}
|
||||
|
||||
// handleListBuilds pages the build history (admin-tier), newest first. The
|
||||
// panel lists from here, so a build started from another browser, or by another
|
||||
// admin, is still there to follow and to cancel.
|
||||
func (a *API) handleListBuilds(w http.ResponseWriter, r *http.Request) {
|
||||
if a.Builder == nil {
|
||||
writeError(w, r, errBuildUnavailable)
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
offset, _ := strconv.Atoi(q.Get("offset"))
|
||||
builds, total, err := a.Builder.ListBuilds(r.Context(), build.ListOpts{
|
||||
Query: q.Get("query"), Limit: limit, Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
writeBuildError(w, r, err)
|
||||
return
|
||||
}
|
||||
if builds == nil {
|
||||
builds = []build.Build{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"builds": builds, "total": total})
|
||||
}
|
||||
|
||||
// handleGetBuild returns a build, reconciling it against its Job first so
|
||||
// polling drives the scan-gate translation without a separate background loop.
|
||||
func (a *API) handleGetBuild(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -30,6 +30,10 @@ type fakeBuilder struct {
|
||||
lastBuildID string
|
||||
admitted map[string]bool
|
||||
admitErr error
|
||||
builds []build.Build
|
||||
buildsTotal int
|
||||
buildsErr error
|
||||
listOpts build.ListOpts
|
||||
}
|
||||
|
||||
func (f *fakeBuilder) Submit(_ context.Context, req build.Request) (*build.Build, error) {
|
||||
@@ -69,6 +73,11 @@ func (f *fakeBuilder) Cancel(_ context.Context, id string) (*build.Build, error)
|
||||
return &build.Build{ID: id, ImageRef: "registry.felis.svc:5000/x:1", Status: build.StatusCancelled}, nil
|
||||
}
|
||||
|
||||
func (f *fakeBuilder) ListBuilds(_ context.Context, opts build.ListOpts) ([]build.Build, int, error) {
|
||||
f.listOpts = opts
|
||||
return f.builds, f.buildsTotal, f.buildsErr
|
||||
}
|
||||
|
||||
func (f *fakeBuilder) ListImages(context.Context) ([]build.Image, error) {
|
||||
return f.images, f.listErr
|
||||
}
|
||||
@@ -110,6 +119,7 @@ func TestImageRoutesAreAdminOnly(t *testing.T) {
|
||||
method, target, body string
|
||||
}{
|
||||
{"POST", "/api/v1/images/build", `{"image_ref":"registry.felis.svc:5000/x:1","dockerfile":"FROM x","context_ref":"c"}`},
|
||||
{"GET", "/api/v1/images/build", ""},
|
||||
{"GET", "/api/v1/images/build/bld-1", ""},
|
||||
{"GET", "/api/v1/images/build/bld-1/logs", ""},
|
||||
{"POST", "/api/v1/images/build/bld-1/cancel", ""},
|
||||
@@ -207,6 +217,43 @@ func TestCancelTerminalBuildIs409(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBuilds(t *testing.T) {
|
||||
fb := &fakeBuilder{
|
||||
builds: []build.Build{{ID: "bld-2", ImageRef: "registry.felis.svc:5000/x:2", Status: build.StatusBuilding}},
|
||||
buildsTotal: 41,
|
||||
}
|
||||
api := adminAPI(fb)
|
||||
w := do(api.ExternalHandler(), "GET", "/api/v1/images/build?query=paper&limit=20&offset=40", "", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if want := (build.ListOpts{Query: "paper", Limit: 20, Offset: 40}); fb.listOpts != want {
|
||||
t.Errorf("forwarded %+v, want %+v", fb.listOpts, want)
|
||||
}
|
||||
var got struct {
|
||||
Builds []build.Build `json:"builds"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("body not JSON: %v", err)
|
||||
}
|
||||
if got.Total != 41 || len(got.Builds) != 1 || got.Builds[0].ID != "bld-2" {
|
||||
t.Fatalf("body = %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// An empty history is an empty list, so the panel never has to handle null.
|
||||
func TestListBuildsEmptyIsAnArray(t *testing.T) {
|
||||
api := adminAPI(&fakeBuilder{})
|
||||
w := do(api.ExternalHandler(), "GET", "/api/v1/images/build", "", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if body := w.Body.String(); body != `{"builds":[],"total":0}`+"\n" {
|
||||
t.Fatalf("body = %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListImages(t *testing.T) {
|
||||
fb := &fakeBuilder{images: []build.Image{
|
||||
{ImageRef: "registry.felis.svc:5000/a:1", Source: build.SourceBuilt, Enabled: true},
|
||||
|
||||
@@ -175,6 +175,20 @@ type Image struct {
|
||||
AddedAt time.Time `json:"added_at"`
|
||||
}
|
||||
|
||||
// ListOpts selects a page of the build history. Query matches a build id or a
|
||||
// status exactly, or any part of the image ref, ignoring case; empty matches all.
|
||||
type ListOpts struct {
|
||||
Query string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// DefaultListLimit and MaxListLimit bound one page of ListBuilds.
|
||||
const (
|
||||
DefaultListLimit = 20
|
||||
MaxListLimit = 100
|
||||
)
|
||||
|
||||
// Store is the business-layer persistence the Builder depends on (image_builds
|
||||
// + image_whitelist). It is an interface so the Builder is tested against an
|
||||
// in-memory fake; the Postgres implementation (pgStore) is integration-tested
|
||||
@@ -191,6 +205,10 @@ type Store interface {
|
||||
// ListUnfinishedBuilds returns builds still being reconciled (status pending
|
||||
// or building), oldest first — the work list for SyncAll.
|
||||
ListUnfinishedBuilds(ctx context.Context) ([]Build, error)
|
||||
// ListBuilds returns one page of the build history, newest first, and how
|
||||
// many builds match in all. The rows leave out the Dockerfile (up to
|
||||
// MaxDockerfileBytes each); GetBuild has it.
|
||||
ListBuilds(ctx context.Context, opts ListOpts) ([]Build, int, error)
|
||||
// AdmitBuiltImage upserts an image_whitelist row with enabled=true and
|
||||
// source=built (the scan-gate success path, spec §16). It records added_by.
|
||||
AdmitBuiltImage(ctx context.Context, img Image) error
|
||||
@@ -501,6 +519,20 @@ func (b *Builder) Get(ctx context.Context, id string) (*Build, error) {
|
||||
return b.Store.GetBuild(ctx, id)
|
||||
}
|
||||
|
||||
// ListBuilds pages the build history for the admin panel, so every admin sees
|
||||
// every build (and can cancel a running one) from any browser. It reads rows as
|
||||
// stored: reconcileBuilds advances them in the background, and GET
|
||||
// /images/build/{id} reconciles one on demand.
|
||||
func (b *Builder) ListBuilds(ctx context.Context, opts ListOpts) ([]Build, int, error) {
|
||||
opts.Query = strings.TrimSpace(opts.Query)
|
||||
if opts.Limit <= 0 {
|
||||
opts.Limit = DefaultListLimit
|
||||
}
|
||||
opts.Limit = min(opts.Limit, MaxListLimit)
|
||||
opts.Offset = max(opts.Offset, 0)
|
||||
return b.Store.ListBuilds(ctx, opts)
|
||||
}
|
||||
|
||||
// Sync reconciles one non-terminal build against its Job phase — the scan-gate
|
||||
// translation (spec §16). A terminal build is returned unchanged (idempotent).
|
||||
//
|
||||
|
||||
@@ -24,6 +24,7 @@ type fakeStore struct {
|
||||
finished []string // "id:status"
|
||||
removeErr error
|
||||
createErr error
|
||||
listOpts ListOpts
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
@@ -88,6 +89,18 @@ func (f *fakeStore) ListUnfinishedBuilds(_ context.Context) ([]Build, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListBuilds(_ context.Context, opts ListOpts) ([]Build, int, error) {
|
||||
f.listOpts = opts
|
||||
var out []Build
|
||||
for _, b := range f.builds {
|
||||
out = append(out, *b)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
total := len(out)
|
||||
out = out[min(opts.Offset, total):min(opts.Offset+opts.Limit, total)]
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) AdmitBuiltImage(_ context.Context, img Image) error {
|
||||
f.images[img.ImageRef] = img
|
||||
f.admitted = append(f.admitted, img)
|
||||
@@ -740,3 +753,26 @@ func TestRecommendedImageAdmittedLikeAnyOtherSource(t *testing.T) {
|
||||
t.Error("a disabled recommended image must not be admitted; curation is not a disable bypass")
|
||||
}
|
||||
}
|
||||
|
||||
// ListBuilds keeps one page bounded whatever the query string asks for.
|
||||
func TestListBuildsBoundsThePage(t *testing.T) {
|
||||
cases := []struct {
|
||||
in ListOpts
|
||||
want ListOpts
|
||||
}{
|
||||
{ListOpts{}, ListOpts{Limit: DefaultListLimit}},
|
||||
{ListOpts{Limit: 5, Offset: 40}, ListOpts{Limit: 5, Offset: 40}},
|
||||
{ListOpts{Limit: 100000}, ListOpts{Limit: MaxListLimit}},
|
||||
{ListOpts{Limit: -3, Offset: -7}, ListOpts{Limit: DefaultListLimit}},
|
||||
{ListOpts{Query: " paper \t"}, ListOpts{Query: "paper", Limit: DefaultListLimit}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
b, st, _ := newBuilder()
|
||||
if _, _, err := b.ListBuilds(context.Background(), c.in); err != nil {
|
||||
t.Fatalf("ListBuilds(%+v): %v", c.in, err)
|
||||
}
|
||||
if st.listOpts != c.want {
|
||||
t.Errorf("ListBuilds(%+v) asked the store for %+v, want %+v", c.in, st.listOpts, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,30 @@ func (s *PGStore) ListUnfinishedBuilds(ctx context.Context) ([]Build, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanBuilds(rows)
|
||||
}
|
||||
|
||||
// ListBuilds pages image_builds newest first. The id breaks created_at ties so
|
||||
// the pages neither repeat nor skip a row; the Dockerfile column is left empty.
|
||||
func (s *PGStore) ListBuilds(ctx context.Context, opts ListOpts) ([]Build, int, error) {
|
||||
const match = ` WHERE $1::text = '' OR id = $1::text OR status::text = lower($1::text)
|
||||
OR strpos(lower(image_ref), lower($1::text)) > 0`
|
||||
var total int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM image_builds`+match, opts.Query).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, image_ref, status, '', context_ref, base_image,
|
||||
requested_by, job_name, log_ref, error, created_at, finished_at, context_digest
|
||||
FROM image_builds`+match+` ORDER BY created_at DESC, id DESC LIMIT $2 OFFSET $3`,
|
||||
opts.Query, opts.Limit, opts.Offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out, err := scanBuilds(rows)
|
||||
return out, total, err
|
||||
}
|
||||
|
||||
func scanBuilds(rows *sql.Rows) ([]Build, error) {
|
||||
defer rows.Close()
|
||||
var out []Build
|
||||
for rows.Next() {
|
||||
|
||||
@@ -1447,6 +1447,70 @@ func TestBuildStoreContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ListBuilds pages newest first across every requester, finds a build by part of
|
||||
// its ref (any case), its id or its status, and leaves the Dockerfile out.
|
||||
func TestBuildStoreListBuilds(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := build.NewPGStore(db)
|
||||
tag := "list-" + suffix(t)
|
||||
base := mustNow().Add(-time.Hour)
|
||||
ids := make([]string, 3)
|
||||
for i := range ids {
|
||||
ids[i] = fmt.Sprintf("bld-%s-%d", tag, i)
|
||||
if err := s.CreateBuild(ctx, &build.Build{ID: ids[i],
|
||||
ImageRef: fmt.Sprintf("registry.felis.svc:5000/%s/img:%d", tag, i), Status: build.StatusPending,
|
||||
RequestedBy: fmt.Sprintf("admin%[email protected]", i), Dockerfile: "FROM scratch\n",
|
||||
CreatedAt: base.Add(time.Duration(i) * time.Minute)}); err != nil {
|
||||
t.Fatalf("CreateBuild(%d): %v", i, err)
|
||||
}
|
||||
}
|
||||
idsOf := func(bs []build.Build) []string {
|
||||
out := []string{}
|
||||
for _, b := range bs {
|
||||
out = append(out, b.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
page, total, err := s.ListBuilds(ctx, build.ListOpts{Query: strings.ToUpper(tag), Limit: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("ListBuilds: %v", err)
|
||||
}
|
||||
if got, want := idsOf(page), []string{ids[2], ids[1]}; total != 3 || fmt.Sprint(got) != fmt.Sprint(want) {
|
||||
t.Fatalf("first page = %v of %d, want %v of 3", got, total, want)
|
||||
}
|
||||
if page[0].Dockerfile != "" || page[0].RequestedBy != "[email protected]" {
|
||||
t.Fatalf("listed row = %+v, want no Dockerfile and the requester", page[0])
|
||||
}
|
||||
page, total, err = s.ListBuilds(ctx, build.ListOpts{Query: tag, Limit: 2, Offset: 2})
|
||||
if err != nil || total != 3 || fmt.Sprint(idsOf(page)) != fmt.Sprint([]string{ids[0]}) {
|
||||
t.Fatalf("second page = %v of %d (%v), want [%s] of 3", idsOf(page), total, err, ids[0])
|
||||
}
|
||||
|
||||
page, total, err = s.ListBuilds(ctx, build.ListOpts{Query: ids[1], Limit: 10})
|
||||
if err != nil || total != 1 || fmt.Sprint(idsOf(page)) != fmt.Sprint([]string{ids[1]}) {
|
||||
t.Fatalf("by id = %v of %d (%v), want [%s]", idsOf(page), total, err, ids[1])
|
||||
}
|
||||
|
||||
if err := s.FinishBuild(ctx, ids[0], build.StatusFailed, "trivy: CRITICAL", mustNow()); err != nil {
|
||||
t.Fatalf("FinishBuild: %v", err)
|
||||
}
|
||||
page, _, err = s.ListBuilds(ctx, build.ListOpts{Query: "Failed", Limit: build.MaxListLimit})
|
||||
if err != nil {
|
||||
t.Fatalf("ListBuilds(status): %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, b := range page {
|
||||
if b.Status != build.StatusFailed {
|
||||
t.Fatalf("status query returned %s in state %s", b.ID, b.Status)
|
||||
}
|
||||
found = found || (b.ID == ids[0] && b.Error == "trivy: CRITICAL")
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("status query missed %s: %v", ids[0], idsOf(page))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers -------------------------------------------------------------------
|
||||
|
||||
func assertAttempts(t *testing.T, userID, purpose string, want int) {
|
||||
|
||||
Reference in new issue
Block a user