Files
Felis/internal/api/images.go
T

322 lines
12 KiB
Go

package api
import (
"context"
"errors"
"net/http"
"strconv"
"felis.lolicon.best/internal/build"
"felis.lolicon.best/internal/imagepin"
"k8s.io/apimachinery/pkg/util/validation"
)
// ImageBuilder is the build-subsystem surface the API depends on (spec §16). It
// is an interface so the image handlers are unit-tested against a fake; the
// production implementation is *build.Builder. All build operations are
// admin-tier (Zero Trust), enforced by adminOnly before these handlers run.
type ImageBuilder interface {
Submit(ctx context.Context, req build.Request) (*build.Build, error)
// GetMany reads the build rows among ids without reconciling them, keyed by
// id — the one read-only lookup a submission list makes to surface each
// build's outcome to its submitter (the /images/build routes are admin-tier).
// State advance belongs to the reconcile loop (Sync/SyncAll), so a list render
// never touches the cluster. An id with no row is absent from the map.
GetMany(ctx context.Context, ids []string) (map[string]build.Build, error)
// Sync reconciles a build against its Job and returns the current view, so a
// 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
// ImageAdmitted reports whether a concrete image ref is whitelisted and
// enabled — the §15 create-server form gate (admission is data-driven, never
// a free image string from the body).
ImageAdmitted(ctx context.Context, imageRef string) (bool, error)
}
// buildImageRequest is the POST /images/build body (spec §16). The push target,
// the uploaded Dockerfile, and the context reference are required; base_image is
// recorded for audit only. The requester identity comes from the Access
// principal, never the body.
type buildImageRequest struct {
ImageRef string `json:"image_ref"`
Dockerfile string `json:"dockerfile"`
ContextRef string `json:"context_ref"`
BaseImage string `json:"base_image,omitempty"`
}
// addImageRequest is the POST /images body for external admission (spec §15).
type addImageRequest struct {
ImageRef string `json:"image_ref"`
}
// handleBuildImage starts a build (admin-tier). It maps validation failures to
// 400 and everything else to the standard envelope.
func (a *API) handleBuildImage(w http.ResponseWriter, r *http.Request) {
if a.Builder == nil {
writeError(w, r, errBuildUnavailable)
return
}
p := principalFromContext(r.Context())
var body buildImageRequest
if err := decodeJSON(w, r, &body); err != nil {
writeError(w, r, err)
return
}
bld, err := a.Builder.Submit(r.Context(), build.Request{
ImageRef: body.ImageRef,
Dockerfile: body.Dockerfile,
ContextRef: body.ContextRef,
BaseImage: body.BaseImage,
RequestedBy: p.Email,
})
if err != nil {
writeBuildError(w, r, err)
return
}
a.audit(r, "image.build", bld.ImageRef)
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) {
if a.Builder == nil {
writeError(w, r, errBuildUnavailable)
return
}
id := r.PathValue("id")
bld, err := a.Builder.Sync(r.Context(), id)
if err != nil {
writeBuildError(w, r, err)
return
}
writeJSON(w, http.StatusOK, bld)
}
// handleBuildLogs (spec §16, §416 日志流复用 §8) streams the build Pod's log to the
// admin as Server-Sent Events. It funnels through relayLogStream — the very same
// §8 read-side relay the server console uses — after a.BuildLogs selects the build
// Job's Pod in the build namespace by build-id label and follows its kaniko
// container. It is admin-tier (adminOnly gates the route); the relay never dials
// RCON and never resolves a secret, it only reads pod logs.
//
// Every error path is resolved BEFORE the relay writes the first SSE byte — once
// the stream headers ship the status code is fixed and no error envelope can
// follow. The buildID becomes a label-selector value (build-id=<id>), so a
// malformed id is rejected up front (400) rather than concatenated into a
// selector, mirroring how handleServerConsole validates the server name before
// touching the streamer.
func (a *API) handleBuildLogs(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// Reject anything that is not a valid label value before it reaches the
// selector: it cannot match a real build Pod (whose build-id label is itself a
// valid label value) and, left unchecked, a crafted id (e.g. one containing a
// comma) could inject a second selector requirement. 400 is the honest answer.
if errs := validation.IsValidLabelValue(id); len(errs) > 0 {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"build id is not a valid build identifier"))
return
}
if a.BuildLogs == nil {
writeError(w, r, newError(http.StatusServiceUnavailable, "build_logs_unavailable",
"build log streaming is not configured"))
return
}
p := principalFromContext(r.Context())
// Bound concurrent SSE streams per principal (shared with the console relay): a
// stalled reader pins this relay and its upstream build-pod follow, so cap how many
// one principal may hold at once. Acquired before opening the stream and released on
// every return path. See streamGate — this bounds blast radius, not the leak itself.
release, ok := a.streamGate().acquire(streamKey(p))
if !ok {
writeError(w, r, newError(http.StatusTooManyRequests, "too_many_streams",
"too many open build-log streams; close one and retry"))
return
}
defer release()
ctx := r.Context()
if since, ok := logSinceFromRequest(r, a.now()); ok {
ctx = withLogSince(ctx, since)
}
src, err := a.BuildLogs.StreamLogs(ctx, id)
switch {
case errors.Is(err, ErrNotFound):
writeError(w, r, newError(http.StatusNotFound, "not_found",
"no build pod found for this id (the build has not started yet, or its pod was cleaned up)"))
return
case errors.Is(err, ErrConsoleUnavailable):
writeError(w, r, newError(http.StatusServiceUnavailable, "build_logs_unavailable",
"build logs are not reachable yet (the build pod may still be pulling its image); retry shortly"))
return
case err != nil:
writeError(w, r, newError(http.StatusInternalServerError, "internal",
"could not open build logs"))
return
}
a.audit(r, "image.build.logs", id)
relayLogStream(w, r, src, a.streamRecheck(r, func(_ context.Context, p *Principal) error {
if !p.IsAdmin() {
return errForbidden
}
return nil
}), a.streamsClosing())
}
// handleCancelBuild cancels an in-flight build (admin-tier). A build that has
// already finished is 409.
func (a *API) handleCancelBuild(w http.ResponseWriter, r *http.Request) {
if a.Builder == nil {
writeError(w, r, errBuildUnavailable)
return
}
id := r.PathValue("id")
bld, err := a.Builder.Cancel(r.Context(), id)
if err != nil {
writeBuildError(w, r, err)
return
}
a.audit(r, "image.build.cancel", bld.ImageRef)
writeJSON(w, http.StatusOK, bld)
}
// handleListImages returns the image whitelist (spec §15: the create-server form
// source).
func (a *API) handleListImages(w http.ResponseWriter, r *http.Request) {
if a.Builder == nil {
writeError(w, r, errBuildUnavailable)
return
}
images, err := a.Builder.ListImages(r.Context())
if err != nil {
writeBuildError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"images": images})
}
// handleAddImage admits an externally-built image (spec §15). It is admin-tier
// and enabled immediately, recorded with added_by for audit.
func (a *API) handleAddImage(w http.ResponseWriter, r *http.Request) {
if a.Builder == nil {
writeError(w, r, errBuildUnavailable)
return
}
p := principalFromContext(r.Context())
var body addImageRequest
if err := decodeJSON(w, r, &body); err != nil {
writeError(w, r, err)
return
}
img, err := a.Builder.AddExternalImage(r.Context(), body.ImageRef, p.Email)
if err != nil {
writeBuildError(w, r, err)
return
}
a.audit(r, "image.admit", img.ImageRef)
writeJSON(w, http.StatusCreated, img)
}
// handleRemoveImage withdraws an image from the whitelist (spec §22). The ref is
// taken from the ?ref= query parameter because image references contain '/' and
// ':' that do not round-trip cleanly through a path segment.
func (a *API) handleRemoveImage(w http.ResponseWriter, r *http.Request) {
if a.Builder == nil {
writeError(w, r, errBuildUnavailable)
return
}
ref := r.URL.Query().Get("ref")
if ref == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"the ?ref= query parameter is required"))
return
}
if err := a.Builder.RemoveImage(r.Context(), ref); err != nil {
writeBuildError(w, r, err)
return
}
a.audit(r, "image.remove", ref)
w.WriteHeader(http.StatusNoContent)
}
// errBuildUnavailable is returned when the build subsystem is not configured on
// this api instance.
var errBuildUnavailable = newError(http.StatusServiceUnavailable, "build_unavailable",
"image build subsystem is not configured")
// writeBuildError maps build-package errors onto HTTP status codes: a validation
// failure is 400, a missing build/image is 404, an already-terminal build is
// 409, and anything else collapses to a 500 by writeError.
func writeBuildError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, build.ErrInvalid):
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "%s", err.Error()))
case errors.Is(err, build.ErrNotFound):
writeError(w, r, newError(http.StatusNotFound, "not_found", "build or image not found"))
case errors.Is(err, build.ErrAlreadyTerminal):
writeError(w, r, newError(http.StatusConflict, "already_terminal",
"build has already finished"))
default:
writeError(w, r, err)
}
}
// ImagePinner resolves an image ref to the immutable form a server's spec keeps
// (imagepin.Resolver). A ref it does not manage comes back unchanged.
type ImagePinner interface {
Pin(ctx context.Context, ref string) (string, error)
}
// pinImage pins an admitted ref for a server spec. A tag the registry does not
// hold is the caller's to fix (build or push it first); any other failure is the
// registry being unreachable, and the server is not created or changed without a
// pin, since an unpinned ref is exactly what lets a later push move its world.
func (a *API) pinImage(ctx context.Context, ref string) (string, error) {
if a.Images == nil {
return ref, nil
}
pinned, err := a.Images.Pin(ctx, ref)
switch {
case errors.Is(err, imagepin.ErrNotFound) && imagepin.Pinned(ref):
return "", newError(http.StatusBadRequest, "image_not_in_registry",
"the registry no longer holds build %q (nothing referenced it, so it was pruned); pick a current tag", ref)
case errors.Is(err, imagepin.ErrNotFound):
return "", newError(http.StatusBadRequest, "image_not_in_registry",
"image %q is whitelisted but the registry does not hold it; build or push it first", ref)
case err != nil:
return "", newError(http.StatusServiceUnavailable, "registry_unavailable",
"could not resolve image %q to a digest: %v", ref, err)
}
return pinned, nil
}