Loading docs/openapi.yaml +34 −0 Changes for docs/openapi.yaml: 34 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading internal/api/api.go +1 −0 Changes for internal/api/api.go: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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}, Loading internal/api/images.go +27 −0 Changes for internal/api/images.go: 27 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" "strconv" "felis.lolicon.best/internal/build" "felis.lolicon.best/internal/imagepin" Loading @@ -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 Loading Loading @@ -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) { Loading internal/api/images_test.go +47 −0 Changes for internal/api/images_test.go: 47 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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) { Loading Loading @@ -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 } Loading Loading @@ -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", ""}, Loading Loading @@ -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}, Loading internal/build/build.go +32 −0 Changes for internal/build/build.go: 32 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading Loading @@ -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 Loading
docs/openapi.yaml +34 −0 Changes for docs/openapi.yaml: 34 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading
internal/api/api.go +1 −0 Changes for internal/api/api.go: 1 added line, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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}, Loading
internal/api/images.go +27 −0 Changes for internal/api/images.go: 27 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" "strconv" "felis.lolicon.best/internal/build" "felis.lolicon.best/internal/imagepin" Loading @@ -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 Loading Loading @@ -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) { Loading
internal/api/images_test.go +47 −0 Changes for internal/api/images_test.go: 47 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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) { Loading Loading @@ -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 } Loading Loading @@ -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", ""}, Loading Loading @@ -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}, Loading
internal/build/build.go +32 −0 Changes for internal/build/build.go: 32 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading Loading @@ -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