feat(build): make the user-modpack build lane read its context (closes the last functional gap)
A submitted modpack was durable but unreadable: the uploads PVC cannot cross
namespaces (felis-api mounts it; Kaniko runs in felis-build) and the s3 lane
handed the sandboxed build Pod no credentials, so NO user build could ever
consume its context. The transport is now the API itself:
- submit: derived context refs become the internal-face URL
/api/v1/internal/submissions/{id}/context (service-token gated), and Blobs
gains Open (local + s3) with an ErrBlobNotFound sentinel for the route's 404.
- api: serves that route on the internal face only (openapi.yaml updated; the
route-coverage test enforces it).
- build: an http(s) context renders a context-fetch initContainer (the felis
image's new fetch-context entrypoint) that streams the blob with the
namespace-local service-token Secret — never mounted into Kaniko — and
extracts it under a zip-slip guard into a size-limited emptyDir that Kaniko
reads read-only as --context=/context.
- platform/install: the api Deployment carries its own internal base URL; the
build namespace gets the token Secret through the existing replica mechanism
(bootstrap.sh + felis setup); the build egress lock opens exactly the control
namespace on the internal port.
- cmd/felis: fetch-context entrypoint (registered, documented, unit-tested for
escapes/symlinks/non-gzip).
Tests cover rendering, hardening, the s3/local Open paths, and the route's
404/503 mapping. Verified next on the real single-node cluster with Kaniko.
This commit is contained in:
26 files changed
+1080
-72
No files matched your search
@@ -271,6 +271,9 @@ func (a *API) internalAPIRoutes() []apiRoute {
|
||||
{Method: "GET", Pattern: "/readyz", Public: true, h: a.handleReadyz},
|
||||
|
||||
{Method: "GET", Pattern: "/api/v1/servers", h: a.handleListServers},
|
||||
// The build Pod's context-fetch initContainer streams a submission's stored
|
||||
// modpack through this route (build namespace cannot mount the uploads PVC).
|
||||
{Method: "GET", Pattern: "/api/v1/internal/submissions/{id}/context", h: a.handleInternalSubmissionContext},
|
||||
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/ready", h: a.handleReady},
|
||||
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/join-event", h: a.handleJoinEvent},
|
||||
// Domain-autostart (spec §9.1, §14): velocity drives the wake lever and polls
|
||||
|
||||
@@ -42,6 +42,11 @@ type SubmissionService interface {
|
||||
// Reject is the admin's other verdict: pending_review -> rejected with a
|
||||
// required reason; it starts no build.
|
||||
Reject(ctx context.Context, id, reviewedBy, reason string) (*submit.Submission, error)
|
||||
// OpenContext returns the stored build-context blob for the internal
|
||||
// context-fetch route: the build Pod's initContainer cannot mount the uploads
|
||||
// PVC across namespaces and holds no object-store credentials, so it streams
|
||||
// the blob from the API over the service-token-gated internal face instead.
|
||||
OpenContext(ctx context.Context, id string) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
// createSubmissionRequest is the POST /me/submissions body. The user
|
||||
@@ -211,6 +216,8 @@ func writeSubmitError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
case errors.Is(err, submit.ErrAlreadyReviewed):
|
||||
writeError(w, r, newError(http.StatusConflict, "already_reviewed",
|
||||
"submission has already been reviewed"))
|
||||
case errors.Is(err, submit.ErrBlobNotFound):
|
||||
writeError(w, r, newError(http.StatusNotFound, "not_found", "no context uploaded for this submission"))
|
||||
case errors.Is(err, submit.ErrUploadsUnavailable):
|
||||
writeError(w, r, newError(http.StatusServiceUnavailable, "uploads_unavailable",
|
||||
"modpack upload transport is not configured"))
|
||||
@@ -219,5 +226,32 @@ func writeSubmitError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleInternalSubmissionContext streams a submission's stored build-context
|
||||
// tarball to the build Pod's `felis fetch-context` initContainer. It lives on the
|
||||
// internal face (service-token, no Zero Trust) because its only caller is
|
||||
// in-cluster infrastructure: the build Job runs in the build namespace, where it
|
||||
// can neither mount the uploads PVC nor hold object-store credentials, so the API
|
||||
// — which wrote the blob — is the transport. The blob is served verbatim; the
|
||||
// fetcher extracts it under a zip-slip guard, and Kaniko treats the result as
|
||||
// hostile regardless (spec §16).
|
||||
func (a *API) handleInternalSubmissionContext(w http.ResponseWriter, r *http.Request) {
|
||||
if a.Submissions == nil {
|
||||
writeError(w, r, errSubmissionsUnavailable)
|
||||
return
|
||||
}
|
||||
rc, err := a.Submissions.OpenContext(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeSubmitError(w, r, err)
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
if _, err := io.Copy(w, rc); err != nil {
|
||||
// The status is already committed; the client sees a truncated stream and
|
||||
// the fetch fails on size/extract, so there is nothing left to write here.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time proof that the production Manager satisfies the API interface.
|
||||
var _ SubmissionService = (*submit.Manager)(nil)
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"felis.lolicon.best/internal/submit"
|
||||
@@ -35,6 +36,9 @@ type fakeSubmissions struct {
|
||||
rejectedBy string
|
||||
rejectReas string
|
||||
rejectErr error
|
||||
openedID string
|
||||
openBody string
|
||||
openErr error
|
||||
}
|
||||
|
||||
func (f *fakeSubmissions) Create(_ context.Context, req submit.CreateRequest) (*submit.Submission, error) {
|
||||
@@ -82,6 +86,16 @@ func (f *fakeSubmissions) Reject(_ context.Context, id, reviewedBy, reason strin
|
||||
return &submit.Submission{ID: id, Status: submit.StatusRejected, ReviewedBy: reviewedBy, RejectReason: reason}, nil
|
||||
}
|
||||
|
||||
// openErr injects the OpenContext outcome; the body recorder lets the internal
|
||||
// route test assert byte-exact streaming and the 404 mapping.
|
||||
func (f *fakeSubmissions) OpenContext(_ context.Context, id string) (io.ReadCloser, error) {
|
||||
f.openedID = id
|
||||
if f.openErr != nil {
|
||||
return nil, f.openErr
|
||||
}
|
||||
return io.NopCloser(strings.NewReader(f.openBody)), nil
|
||||
}
|
||||
|
||||
// appSubAPI wires a submissions service behind an ordinary user principal (the
|
||||
// app tier — /me/submissions). [email protected] / .test are deliberately not the
|
||||
// deployment domain.
|
||||
@@ -396,3 +410,46 @@ func TestSubmissionRoutesWithoutServiceAre503(t *testing.T) {
|
||||
t.Fatalf("admin route: code = %d, want 503", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The internal context route is the build Pod's only read path to a submission's
|
||||
// blob: it streams the bytes verbatim, and its error mapping distinguishes a
|
||||
// missing blob (404) from an unwired transport (503).
|
||||
func TestInternalSubmissionContextRoute(t *testing.T) {
|
||||
newAPI := func(s SubmissionService) *API {
|
||||
api := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||
api.Submissions = s
|
||||
return api
|
||||
}
|
||||
|
||||
t.Run("streams the blob", func(t *testing.T) {
|
||||
fs := &fakeSubmissions{openBody: "\x1f\x8b\x08\x00blob"}
|
||||
w := do(newAPI(fs).InternalHandler(), "GET", "/api/v1/internal/submissions/sub-7/context", "", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w.Body.String() != fs.openBody {
|
||||
t.Fatalf("body = %q, want the stored blob %q", w.Body.String(), fs.openBody)
|
||||
}
|
||||
if fs.openedID != "sub-7" {
|
||||
t.Fatalf("opened id = %q, want the path id", fs.openedID)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "application/gzip" {
|
||||
t.Fatalf("content-type = %q, want application/gzip", ct)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing blob is 404", func(t *testing.T) {
|
||||
fs := &fakeSubmissions{openErr: fmt.Errorf("%w: gone", submit.ErrBlobNotFound)}
|
||||
w := do(newAPI(fs).InternalHandler(), "GET", "/api/v1/internal/submissions/sub-7/context", "", nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d, want 404", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unwired transport is 503", func(t *testing.T) {
|
||||
w := do(newAPI(nil).InternalHandler(), "GET", "/api/v1/internal/submissions/sub-7/context", "", nil)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("code = %d, want 503", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user