Unverified Commit 06d5e652 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(panel): 构建记录改由服务端分页列表提供,任何浏览器与管理员都能看到并取消进行中的构建,列表刷新失败保留原行并提示

parent 9d03386c
Loading
Loading
Loading
Loading
+34 −0
Changes for docs/openapi.yaml: 34 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -4755,6 +4755,40 @@ paths:
          $ref: '#/components/responses/ServiceUnavailable'

  /api/v1/images/build:
    get:
      tags: [images]
      operationId: listBuilds
      summary: List builds (admin), newest first.
      description: >-
        One page of the build history across every admin. Rows are read as stored
        (the reconcile loop advances them; GET /images/build/{id} reconciles one on
        demand) and leave out the Dockerfile, which GET /images/build/{id} returns.
      x-felis-face: [external]
      x-felis-tier: admin
      security: [{ accessJWT: [] }]
      parameters:
        - { name: query, in: query, required: false, schema: { type: string }, description: 'Build id or status (exact), or part of the image ref; case-insensitive' }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 } }
        - { name: offset, in: query, required: false, schema: { type: integer, default: 0 } }
      responses:
        '200':
          description: A page of builds plus how many match the query.
          content:
            application/json:
              schema:
                type: object
                required: [builds, total]
                properties:
                  builds:
                    type: array
                    items: { $ref: '#/components/schemas/Build' }
                  total: { type: integer }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
    post:
      tags: [images]
      operationId: buildImage
+1 −0
Changes for internal/api/api.go: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -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},
+27 −0
Changes for internal/api/images.go: 27 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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) {
+47 −0
Changes for internal/api/images_test.go: 47 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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},
+32 −0
Changes for internal/build/build.go: 32 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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).
//
Loading