fix(files): 模组包上传也逐段核对 SHA-256、长文件名能写、卡住的导出按时停掉、世界被占用时文件页说明原因并等它结束
This commit is contained in:
59 files changed
+2538
-338
No files matched your search
+3
-1
@@ -464,10 +464,12 @@ func (a *API) internalAPIRoutes() []apiRoute {
|
||||
// Principal); the shared enqueueBackup tail enforces the RWO stopped-gate.
|
||||
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/backup", Callers: ops, h: a.handleInternalBackup},
|
||||
|
||||
// A file upload's staged bytes, fetched once by the Job landing them. Public
|
||||
// A file upload's staged bytes, fetched once by the Job landing them, which
|
||||
// then reports them landed so a file sent in parts is deleted. Public
|
||||
// because that Job holds no service token; the one-time bearer token minted
|
||||
// with the upload is the check (handlers_files.go).
|
||||
{Method: "GET", Pattern: "/api/v1/internal/file-uploads/{id}", Public: true, h: a.handleInternalFileUpload},
|
||||
{Method: "DELETE", Pattern: "/api/v1/internal/file-uploads/{id}", Public: true, h: a.handleInternalFileUploadLanded},
|
||||
|
||||
// An export Job's archive, held open until the owner's browser downloads
|
||||
// it. Public for the same reason as file uploads: the Job holds no service
|
||||
|
||||
+104
-11
@@ -44,6 +44,12 @@ import (
|
||||
// archive passes through felis-api's memory once and never touches a disk
|
||||
// it owns, and the Job moves at the browser's pace.
|
||||
//
|
||||
// Nothing on the way is trusted to deliver the bytes intact. The Job hashes
|
||||
// what it sends and ends its chunked PUT with the SHA-256 as a trailer;
|
||||
// felis-api hashes what it receives and holds the last buffer back from the
|
||||
// browser until the two agree (relayExport), so a download whose bytes changed
|
||||
// between the Job and here fails in the browser rather than lands complete.
|
||||
//
|
||||
// A backup is checked by the Job against the sha256 recorded when it was
|
||||
// written (cmd/felis export): on a mismatch the Job aborts its upload short of
|
||||
// the archive's end, the download aborts with it, and the browser reports a
|
||||
@@ -54,9 +60,10 @@ import (
|
||||
|
||||
// Exporter starts the Job that archives a world or a backup and hands it to the
|
||||
// internal upload route (internal/worldexport). Optional: when nil the export
|
||||
// routes answer 503.
|
||||
// routes answer 503. Stop deletes a Job felis-api has given up on.
|
||||
type Exporter interface {
|
||||
Start(ctx context.Context, r worldexport.Request) (job string, err error)
|
||||
Stop(ctx context.Context, job string) error
|
||||
}
|
||||
|
||||
// Limits on exports. Each one keeps a Job, a connection and a 64 KiB copy
|
||||
@@ -176,8 +183,10 @@ type exportEntry struct {
|
||||
|
||||
// exportUpload is the Job's PUT, parked until a browser claims it.
|
||||
type exportUpload struct {
|
||||
body io.Reader
|
||||
size int64 // -1 when the Job streams it chunked
|
||||
body io.Reader
|
||||
size int64 // what the Job declared (worldexport.LengthHeader); -1 when it did not
|
||||
// digest reads the Job's trailer, which is there once body has ended.
|
||||
digest func() []string
|
||||
claimed chan struct{}
|
||||
done chan error // how the download ended; buffered
|
||||
}
|
||||
@@ -191,6 +200,9 @@ type exportRegistry struct {
|
||||
byTicket map[string]*exportEntry
|
||||
byID map[string]*exportEntry
|
||||
starts map[exportStarts][]time.Time // oldest first
|
||||
// stop deletes the Job of an export the sweep expired while it was
|
||||
// pending, and is run apart from the lock.
|
||||
stop func(job string)
|
||||
}
|
||||
|
||||
func (a *API) exportTickets() *exportRegistry {
|
||||
@@ -199,17 +211,41 @@ func (a *API) exportTickets() *exportRegistry {
|
||||
byTicket: map[string]*exportEntry{},
|
||||
byID: map[string]*exportEntry{},
|
||||
starts: map[exportStarts][]time.Time{},
|
||||
stop: a.stopExportJob,
|
||||
}
|
||||
})
|
||||
return a.exports
|
||||
}
|
||||
|
||||
// ExpireExports runs the export sweep on its own, for felis-api's loop: the
|
||||
// routes sweep as they are called, and an owner who closed the tab calls none.
|
||||
func (a *API) ExpireExports() {
|
||||
g := a.exportTickets()
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.sweepLocked(a.now())
|
||||
}
|
||||
|
||||
// stopExportJob deletes one export Job. A delete that fails leaves the Job to
|
||||
// its own deadline.
|
||||
func (a *API) stopExportJob(job string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := a.Exporter.Stop(ctx, job); err != nil {
|
||||
log.Printf("api: stop export job %s, which never connected: %v", job, err)
|
||||
}
|
||||
}
|
||||
|
||||
// sweepLocked expires what has waited too long and forgets what ended long ago.
|
||||
// A Job still pending at its TTL never connected: its Pod is stuck unscheduled
|
||||
// or pulling, and until its deadline it would keep the server from starting,
|
||||
// so it is deleted.
|
||||
func (g *exportRegistry) sweepLocked(now time.Time) {
|
||||
for t, e := range g.byTicket {
|
||||
switch {
|
||||
case e.state == exportPending && now.Sub(e.at) >= exportPendingTTL:
|
||||
e.state, e.at = exportSpent, now
|
||||
go g.stop(e.job)
|
||||
case !e.active() && now.Sub(e.at) >= exportKeepSpent:
|
||||
delete(g.byTicket, t)
|
||||
delete(g.byID, e.id)
|
||||
@@ -661,7 +697,7 @@ func (a *API) handleExportDownload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
err = copyExport(w, e.upload.body)
|
||||
n, sum, err := relayExport(w, e.upload)
|
||||
e.upload.done <- err
|
||||
if err != nil {
|
||||
log.Printf("api: export %s of %s ended early: %v", e.id, e.server, err)
|
||||
@@ -669,17 +705,64 @@ func (a *API) handleExportDownload(w http.ResponseWriter, r *http.Request) {
|
||||
// read as failed in the browser, never as a complete file.
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
log.Printf("api: export %s of %s sent %d bytes, sha256 %s", e.id, e.server, n, sum)
|
||||
}
|
||||
|
||||
// copyExport copies body into w through a fixed 32 KiB buffer, restarting w's
|
||||
// write deadline on every write.
|
||||
func copyExport(w http.ResponseWriter, body io.Reader) error {
|
||||
var (
|
||||
errExportDigest = errors.New("the bytes the export Job sent do not match the SHA-256 it sent with them")
|
||||
errExportLength = errors.New("the export Job sent a different number of bytes than it declared")
|
||||
)
|
||||
|
||||
// relayExport copies the Job's upload into the download through fixed 32 KiB
|
||||
// buffers, restarting w's write deadline on every write, and checks it on the
|
||||
// way: the bytes must hash to the SHA-256 the Job's trailer carries once it has
|
||||
// sent them all (worldexport.DigestTrailer), and number what it declared, when
|
||||
// it did. The latest buffer read is held back until both hold, so bytes changed
|
||||
// between the Job and here, or a Job that sent no digest, end the download
|
||||
// short of its end, and the browser reports it failed rather than keep a
|
||||
// complete-looking corrupt file. It returns the bytes sent and their SHA-256.
|
||||
func relayExport(w http.ResponseWriter, up *exportUpload) (int64, string, error) {
|
||||
out := &stallWriter{w: w, rc: http.NewResponseController(w)}
|
||||
if _, err := io.CopyBuffer(out, body, make([]byte, exportCopyBuffer)); err != nil {
|
||||
return err
|
||||
h := sha256.New()
|
||||
var n int64
|
||||
next, spare := make([]byte, exportCopyBuffer), make([]byte, exportCopyBuffer)
|
||||
var held []byte
|
||||
for {
|
||||
k, err := up.body.Read(next)
|
||||
if k > 0 {
|
||||
if len(held) > 0 {
|
||||
if _, werr := out.Write(held); werr != nil {
|
||||
return n, "", werr
|
||||
}
|
||||
}
|
||||
h.Write(next[:k])
|
||||
n += int64(k)
|
||||
held = next[:k]
|
||||
next, spare = spare, next
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return n, "", err
|
||||
}
|
||||
}
|
||||
want, err := parseContentDigest(up.digest())
|
||||
switch {
|
||||
case up.size >= 0 && n != up.size:
|
||||
return n, "", fmt.Errorf("%w: %d of %d", errExportLength, n, up.size)
|
||||
case err != nil:
|
||||
return n, "", fmt.Errorf("%w: %v", errExportDigest, err)
|
||||
case subtle.ConstantTimeCompare(h.Sum(nil), want) != 1:
|
||||
return n, "", errExportDigest
|
||||
}
|
||||
if len(held) > 0 {
|
||||
if _, err := out.Write(held); err != nil {
|
||||
return n, "", err
|
||||
}
|
||||
}
|
||||
_ = out.rc.SetWriteDeadline(time.Time{}) // the connection may serve another request
|
||||
return nil
|
||||
return n, hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// stallWriter restarts the connection's write deadline before every write, so
|
||||
@@ -708,9 +791,19 @@ func (a *API) handleInternalExportUpload(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, r, errNoExport())
|
||||
return
|
||||
}
|
||||
size := int64(-1)
|
||||
if v := r.Header.Get(worldexport.LengthHeader); v != "" {
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || n < 0 {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "%s must be a byte count", worldexport.LengthHeader))
|
||||
return
|
||||
}
|
||||
size = n
|
||||
}
|
||||
rc := takeBodyDeadline(w, r)
|
||||
up := &exportUpload{
|
||||
body: &stallBody{r: r.Body, rc: rc}, size: r.ContentLength,
|
||||
body: &stallBody{r: r.Body, rc: rc}, size: size,
|
||||
digest: func() []string { return r.Trailer.Values(worldexport.DigestTrailer) },
|
||||
claimed: make(chan struct{}), done: make(chan error, 1),
|
||||
}
|
||||
reg := a.exportTickets()
|
||||
|
||||
+125
-14
@@ -31,6 +31,14 @@ import (
|
||||
type fakeExporter struct {
|
||||
reqs []worldexport.Request
|
||||
err error
|
||||
// stopped receives each Job Stop is asked to delete; the sweep asks from
|
||||
// a goroutine of its own.
|
||||
stopped chan string
|
||||
}
|
||||
|
||||
func (f *fakeExporter) Stop(_ context.Context, job string) error {
|
||||
f.stopped <- job
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeExporter) Start(_ context.Context, r worldexport.Request) (string, error) {
|
||||
@@ -92,7 +100,7 @@ func exportFixture() (*API, *fakeRepo, *fakeCluster, *fakeExporter) {
|
||||
for _, name := range []string{"survival", "gamma"} {
|
||||
cl.byName[name] = &ServerInfo{Name: name, Phase: "Stopped", DesiredState: string(v1alpha1.DesiredStopped)}
|
||||
}
|
||||
ex := &fakeExporter{}
|
||||
ex := &fakeExporter{stopped: make(chan string, 64)}
|
||||
a := newTestAPI(repo, cl)
|
||||
a.External = exportUsers
|
||||
a.Exporter, a.InternalBaseURL = ex, exportBase
|
||||
@@ -140,11 +148,15 @@ func waitExportReady(t *testing.T, h http.Handler, ticket, user string) {
|
||||
}
|
||||
|
||||
// uploadExport serves the Job's PUT on the internal face in the background.
|
||||
func uploadExport(h http.Handler, id, token string, body io.Reader) <-chan *httptest.ResponseRecorder {
|
||||
// digest, when set, is the trailer the Job sends once its body has ended.
|
||||
func uploadExport(h http.Handler, id, token string, body io.Reader, digest string) <-chan *httptest.ResponseRecorder {
|
||||
out := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() {
|
||||
r := httptest.NewRequest("PUT", "/api/v1/internal/exports/"+id, body)
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
if digest != "" {
|
||||
r.Trailer = http.Header{worldexport.DigestTrailer: {digest}}
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
out <- w
|
||||
@@ -167,6 +179,18 @@ func awaitUpload(t *testing.T, up <-chan *httptest.ResponseRecorder) *httptest.R
|
||||
// claimed the export by mistake would block on the parked body, and an upload
|
||||
// let in by mistake would wait for a browser, so either fails the test after a
|
||||
// bound instead of hanging it.
|
||||
// awaitStop returns the next Job the sweep asked to delete.
|
||||
func awaitStop(t *testing.T, ex *fakeExporter) string {
|
||||
t.Helper()
|
||||
select {
|
||||
case job := <-ex.stopped:
|
||||
return job
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("no Job was stopped")
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func doSoon(t *testing.T, h http.Handler, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
out := make(chan *httptest.ResponseRecorder, 1)
|
||||
@@ -341,7 +365,7 @@ func TestExportWorldGate(t *testing.T) {
|
||||
w := do(a.ExternalHandler(), "POST", worldPath, "", as("admin1"))
|
||||
var raw map[string]map[string]string
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &raw)
|
||||
if want := "a world export is running on this server's world; retry once it finishes"; w.Code != http.StatusConflict ||
|
||||
if want := "a world export or file download is running on this server's world; retry once it finishes"; w.Code != http.StatusConflict ||
|
||||
raw["error"]["code"] != "maintenance_in_progress" || raw["error"]["message"] != want {
|
||||
t.Fatalf("busy = %d %s, want 409 %q", w.Code, w.Body.String(), want)
|
||||
}
|
||||
@@ -459,9 +483,17 @@ func TestExportRendezvous(t *testing.T) {
|
||||
t.Fatalf("PUT to another id = %d", w.Code)
|
||||
}
|
||||
|
||||
// A length that is no byte count is refused before the token is spent.
|
||||
for _, bad := range []string{"-1", "12abc", "0x10"} {
|
||||
if w := doSoon(t, in, "PUT", "/api/v1/internal/exports/"+job.ID, "x", map[string]string{
|
||||
"Authorization": "Bearer " + job.Token, worldexport.LengthHeader: bad}); w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
|
||||
t.Fatalf("PUT declaring %q bytes = %d %s", bad, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
archive := randomBytes(3*exportCopyBuffer + 4321)
|
||||
pr, pw := io.Pipe()
|
||||
up := uploadExport(in, job.ID, job.Token, pr)
|
||||
up := uploadExport(in, job.ID, job.Token, pr, contentDigestOf(string(archive)))
|
||||
waitExportReady(t, ext, v.Ticket, "owner1")
|
||||
if w := doSoon(t, in, "PUT", "/api/v1/internal/exports/"+job.ID, "x", map[string]string{"Authorization": "Bearer " + job.Token}); w.Code != http.StatusNotFound {
|
||||
t.Fatalf("a second PUT with the spent token = %d, want 404", w.Code)
|
||||
@@ -541,11 +573,47 @@ func TestExportExpiry(t *testing.T) {
|
||||
t.Fatalf("past the pending TTL: %d %+v", code, s)
|
||||
}
|
||||
job := ex.reqs[0]
|
||||
if got := awaitStop(t, ex); got != worldexport.JobName(job.Server, job.ID) {
|
||||
t.Fatalf("stopped %q, want the export's own Job", got)
|
||||
}
|
||||
if w := doSoon(t, a.InternalHandler(), "PUT", "/api/v1/internal/exports/"+job.ID, "x", map[string]string{"Authorization": "Bearer " + job.Token}); w.Code != http.StatusNotFound {
|
||||
t.Fatalf("a late Job's PUT = %d, want 404", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
// The owner closed the tab, so no route sweeps; felis-api's loop does.
|
||||
t.Run("the loop stops a Job left pending, and only that one", func(t *testing.T) {
|
||||
a, _, _, ex := exportFixture()
|
||||
var clock atomic.Int64
|
||||
clock.Store(1_700_000_000)
|
||||
a.Now = func() time.Time { return time.Unix(clock.Load(), 0) }
|
||||
ext := a.ExternalHandler()
|
||||
beginExport(t, ext, worldPath, "owner1")
|
||||
stuck := ex.reqs[0]
|
||||
v := beginExport(t, ext, "/api/v1/servers/creative/backups/bk2/export", "admin1")
|
||||
moving := ex.reqs[1]
|
||||
uploadExport(a.InternalHandler(), moving.ID, moving.Token, strings.NewReader("archive"), contentDigestOf("archive"))
|
||||
waitExportReady(t, ext, v.Ticket, "admin1")
|
||||
|
||||
clock.Add(int64(exportPendingTTL/time.Second) - 1)
|
||||
a.ExpireExports()
|
||||
select {
|
||||
case job := <-ex.stopped:
|
||||
t.Fatalf("stopped %s inside the pending TTL", job)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
clock.Add(1)
|
||||
a.ExpireExports()
|
||||
if got := awaitStop(t, ex); got != worldexport.JobName(stuck.Server, stuck.ID) {
|
||||
t.Fatalf("stopped %q, want the Job that never connected", got)
|
||||
}
|
||||
select {
|
||||
case job := <-ex.stopped:
|
||||
t.Fatalf("also stopped %s, whose upload was waiting for its browser", job)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a browser that never comes", func(t *testing.T) {
|
||||
defer func(old time.Duration) { exportClaimTTL = old }(exportClaimTTL)
|
||||
exportClaimTTL = 30 * time.Millisecond
|
||||
@@ -553,7 +621,7 @@ func TestExportExpiry(t *testing.T) {
|
||||
ext := a.ExternalHandler()
|
||||
v := beginExport(t, ext, backupPath, "owner1")
|
||||
job := ex.reqs[0]
|
||||
w := awaitUpload(t, uploadExport(a.InternalHandler(), job.ID, job.Token, strings.NewReader("archive")))
|
||||
w := awaitUpload(t, uploadExport(a.InternalHandler(), job.ID, job.Token, strings.NewReader("archive"), contentDigestOf("archive")))
|
||||
if w.Code != http.StatusGone || decodeErr(t, w) != "export_expired" {
|
||||
t.Fatalf("unclaimed upload = %d %s, want 410 export_expired", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -639,12 +707,20 @@ func exportServers(t *testing.T, a *API) (ext, in *httptest.Server) {
|
||||
return ext, in
|
||||
}
|
||||
|
||||
func realUpload(t *testing.T, in *httptest.Server, job worldexport.Request, body io.Reader, size int64) <-chan *http.Response {
|
||||
// realUpload sends the Job's PUT as cmd/felis export does: chunked, with the
|
||||
// size when it is known (not -1) and, when set, digest as the trailer.
|
||||
func realUpload(t *testing.T, in *httptest.Server, job worldexport.Request, body io.Reader, size int64, digest string) <-chan *http.Response {
|
||||
req, err := http.NewRequest("PUT", in.URL+"/api/v1/internal/exports/"+job.ID, body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.ContentLength = size
|
||||
req.ContentLength = -1
|
||||
if size >= 0 {
|
||||
req.Header.Set(worldexport.LengthHeader, strconv.FormatInt(size, 10))
|
||||
}
|
||||
if digest != "" {
|
||||
req.Trailer = http.Header{worldexport.DigestTrailer: {digest}}
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+job.Token)
|
||||
out := make(chan *http.Response, 1)
|
||||
go func() {
|
||||
@@ -696,7 +772,7 @@ func TestExportStreamsWhatTheJobSends(t *testing.T) {
|
||||
a, _, _, ex := exportFixture()
|
||||
ext, in := exportServers(t, a)
|
||||
v := beginExport(t, a.ExternalHandler(), backupPath, "owner1")
|
||||
up := realUpload(t, in, ex.reqs[0], bytes.NewReader(archive), int64(len(archive)))
|
||||
up := realUpload(t, in, ex.reqs[0], bytes.NewReader(archive), int64(len(archive)), contentDigestOf(string(archive)))
|
||||
waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1")
|
||||
resp, got, err := realDownload(ext, v.Ticket)
|
||||
if resp == nil {
|
||||
@@ -723,7 +799,7 @@ func TestExportStreamsWhatTheJobSends(t *testing.T) {
|
||||
_, _ = pw.Write(archive[:len(archive)-100])
|
||||
pw.CloseWithError(errors.New("the backup archive does not match the sha256 recorded when it was written"))
|
||||
}()
|
||||
up := realUpload(t, in, ex.reqs[0], pr, -1)
|
||||
up := realUpload(t, in, ex.reqs[0], pr, -1, contentDigestOf(string(archive)))
|
||||
waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1")
|
||||
resp, got, err := realDownload(ext, v.Ticket)
|
||||
if resp == nil {
|
||||
@@ -737,11 +813,46 @@ func TestExportStreamsWhatTheJobSends(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// The Job's trailer is the SHA-256 of what it sent. Bytes that arrive
|
||||
// otherwise, or without it, or not as many as it declared, never reach the
|
||||
// browser to their end, and the Job hears the download failed.
|
||||
t.Run("bytes changed on the way never reach the browser whole", func(t *testing.T) {
|
||||
flipped := bytes.Clone(archive)
|
||||
flipped[len(flipped)/2] ^= 1
|
||||
for _, c := range []struct {
|
||||
name, digest string
|
||||
size int64
|
||||
}{
|
||||
{"another archive's digest", contentDigestOf(string(flipped)), int64(len(archive))},
|
||||
{"no digest", "", -1},
|
||||
{"a digest that is no SHA-256", "sha-256=:AAAA:", -1},
|
||||
{"one byte more declared than sent", contentDigestOf(string(archive)), int64(len(archive)) + 1},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
a, _, _, ex := exportFixture()
|
||||
ext, in := exportServers(t, a)
|
||||
v := beginExport(t, a.ExternalHandler(), backupPath, "owner1")
|
||||
up := realUpload(t, in, ex.reqs[0], bytes.NewReader(archive), c.size, c.digest)
|
||||
waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1")
|
||||
resp, got, err := realDownload(ext, v.Ticket)
|
||||
if resp == nil {
|
||||
t.Fatalf("download: %v", err)
|
||||
}
|
||||
if err == nil || len(got) >= len(archive) || !bytes.Equal(got, archive[:len(got)]) {
|
||||
t.Fatalf("read %d of %d bytes, err %v; want an error short of the end", len(got), len(archive), err)
|
||||
}
|
||||
if upResp := awaitResponse(t, up); upResp.StatusCode != http.StatusGone {
|
||||
t.Fatalf("the upload answered %d, want 410", upResp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a browser that leaves early is what the Job hears", func(t *testing.T) {
|
||||
a, _, _, ex := exportFixture()
|
||||
ext, _ := exportServers(t, a)
|
||||
v := beginExport(t, a.ExternalHandler(), worldPath, "owner1")
|
||||
up := uploadExport(a.InternalHandler(), ex.reqs[0].ID, ex.reqs[0].Token, io.LimitReader(zeros{}, 1<<30))
|
||||
up := uploadExport(a.InternalHandler(), ex.reqs[0].ID, ex.reqs[0].Token, io.LimitReader(zeros{}, 1<<30), "")
|
||||
waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1")
|
||||
req, _ := http.NewRequest("GET", ext.URL+"/api/v1/exports/"+v.Ticket+"/download", nil)
|
||||
req.Header.Set("X-Test-User", "owner1")
|
||||
@@ -957,7 +1068,7 @@ func TestFileDownloadServed(t *testing.T) {
|
||||
// Past what the server would buffer and measure itself when the
|
||||
// handler sets no length.
|
||||
body := randomBytes(64 << 10)
|
||||
up := realUpload(t, in, ex.reqs[0], bytes.NewReader(body), tc.size)
|
||||
up := realUpload(t, in, ex.reqs[0], bytes.NewReader(body), tc.size, contentDigestOf(string(body)))
|
||||
waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1")
|
||||
resp, got, err := realDownload(ext, v.Ticket)
|
||||
if resp == nil || err != nil || !bytes.Equal(got, body) {
|
||||
@@ -1015,7 +1126,7 @@ func TestExportUploadPace(t *testing.T) {
|
||||
_, _ = pw.Write(archive[1000:])
|
||||
pw.Close()
|
||||
}()
|
||||
up := realUpload(t, in, ex.reqs[0], pr, -1)
|
||||
up := realUpload(t, in, ex.reqs[0], pr, -1, contentDigestOf(string(archive)))
|
||||
waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1")
|
||||
time.Sleep(3 * bodyGrace)
|
||||
_, got, err := realDownload(ext, v.Ticket)
|
||||
@@ -1034,7 +1145,7 @@ func TestExportUploadPace(t *testing.T) {
|
||||
pr, pw := io.Pipe()
|
||||
defer pw.Close()
|
||||
go func() { _, _ = pw.Write(make([]byte, 1000)) }() // then nothing, ever
|
||||
up := realUpload(t, in, ex.reqs[0], pr, -1)
|
||||
up := realUpload(t, in, ex.reqs[0], pr, -1, "")
|
||||
waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1")
|
||||
done := make(chan error, 1)
|
||||
go func() { _, _, err := realDownload(ext, v.Ticket); done <- err }()
|
||||
@@ -1054,7 +1165,7 @@ func TestExportUploadPace(t *testing.T) {
|
||||
a, _, _, ex := exportFixture()
|
||||
ext, in := exportServers(t, a)
|
||||
v := beginExport(t, a.ExternalHandler(), worldPath, "owner1")
|
||||
realUpload(t, in, ex.reqs[0], io.LimitReader(zeros{}, 1<<30), -1)
|
||||
realUpload(t, in, ex.reqs[0], io.LimitReader(zeros{}, 1<<30), -1, "")
|
||||
waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1")
|
||||
req, _ := http.NewRequest("GET", ext.URL+"/api/v1/exports/"+v.Ticket+"/download", nil)
|
||||
req.Header.Set("X-Test-User", "owner1")
|
||||
|
||||
@@ -29,17 +29,50 @@ import (
|
||||
// the world volume while it runs, as any file write does, and the server cannot
|
||||
// start until it ends.
|
||||
|
||||
// fileSessionView is where an upload session stands.
|
||||
// fileSessionView is where an upload session stands. Parts are the parts it
|
||||
// took, in order, each with the SHA-256 it arrived with: a client resuming from
|
||||
// a file on disk checks the file still holds those bytes before it sends the
|
||||
// rest.
|
||||
type fileSessionView struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Received int64 `json:"received"`
|
||||
PartMaxBytes int64 `json:"part_max_bytes"`
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Received int64 `json:"received"`
|
||||
PartMaxBytes int64 `json:"part_max_bytes"`
|
||||
Parts []filePartView `json:"parts"`
|
||||
}
|
||||
|
||||
// filePartView is one part a session took.
|
||||
type filePartView struct {
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
func sessionView(s fileedit.Session) fileSessionView {
|
||||
return fileSessionView{ID: s.ID, Path: s.Path, Size: s.Size, Received: s.Received, PartMaxBytes: fileedit.PartBytes}
|
||||
parts := make([]filePartView, 0, len(s.Parts))
|
||||
for _, p := range s.Parts {
|
||||
parts = append(parts, filePartView{Size: p.Size, SHA256: p.SHA256})
|
||||
}
|
||||
return fileSessionView{
|
||||
ID: s.ID, Path: s.Path, Size: s.Size, Received: s.Received,
|
||||
PartMaxBytes: fileedit.PartBytes, Parts: parts,
|
||||
}
|
||||
}
|
||||
|
||||
// namesFit reports whether every name in path fits one folder entry.
|
||||
func namesFit(path string) bool {
|
||||
for _, name := range strings.Split(path, "/") {
|
||||
if len(name) > fileedit.NameMax {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// errNameTooLong refuses a path namesFit rejects, before any byte is taken:
|
||||
// the Job would only find out once it tried to create the file.
|
||||
func errNameTooLong() *apiError {
|
||||
return newError(http.StatusBadRequest, "bad_path", "a name in the path is longer than %d bytes", fileedit.NameMax)
|
||||
}
|
||||
|
||||
// beginFileUploadRequest is the POST …/files/uploads body.
|
||||
@@ -73,6 +106,10 @@ func (a *API) handleBeginFileUpload(w http.ResponseWriter, r *http.Request) {
|
||||
"the path must name a file inside the world folder"))
|
||||
return
|
||||
}
|
||||
if !namesFit(path) {
|
||||
writeError(w, r, errNameTooLong())
|
||||
return
|
||||
}
|
||||
var body beginFileUploadRequest
|
||||
if err := decodeJSON(w, r, &body); err != nil {
|
||||
writeError(w, r, err)
|
||||
@@ -111,9 +148,11 @@ func (a *API) handleFileUploadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// /api/v1/servers/{name}/files/uploads/{id}?offset=… — append the raw body to
|
||||
// the caller's session. offset must be where the session ends (409
|
||||
// upload_offset_mismatch otherwise; the status says where), and Content-Length
|
||||
// is required, as for the one-request upload: the part is taken whole or not at
|
||||
// all, and a part that breaks midway leaves the session where it was. A part
|
||||
// over fileedit.PartBytes is refused before a byte of it is read (Append).
|
||||
// and Content-Digest are required, as for the one-request upload: the part is
|
||||
// taken whole or not at all, and a part that breaks midway, or whose bytes do
|
||||
// not hash to its digest (400 digest_mismatch), leaves the session where it
|
||||
// was. A part over fileedit.PartBytes is refused before a byte of it is read
|
||||
// (Append).
|
||||
func (a *API) handleFileUploadPart(w http.ResponseWriter, r *http.Request) {
|
||||
name, user, ok := a.authorizeFileSession(w, r)
|
||||
if !ok {
|
||||
@@ -130,7 +169,11 @@ func (a *API) handleFileUploadPart(w http.ResponseWriter, r *http.Request) {
|
||||
"a part needs a Content-Length"))
|
||||
return
|
||||
}
|
||||
s, err := a.FileStage.Append(user, name, r.PathValue("id"), offset, r.Body, r.ContentLength)
|
||||
want, ok := contentDigest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s, err := a.FileStage.Append(user, name, r.PathValue("id"), offset, r.Body, r.ContentLength, want)
|
||||
if err != nil {
|
||||
writeFileSessionError(w, r, err)
|
||||
return
|
||||
@@ -166,8 +209,9 @@ type startFileOpRequest struct {
|
||||
// The world lock is taken BEFORE the session is sealed: a commit made while an
|
||||
// earlier commit's Job is still fetching the file is refused by that Job's hold
|
||||
// on the volume, so it never mints the fresh token that would lock the running
|
||||
// Job out. The session outlives a Job that fails before fetching every byte, so
|
||||
// such a commit is simply made again; one that fetched them all is gone.
|
||||
// Job out. The session outlives a Job that fails for any reason, so such a
|
||||
// commit is simply made again; the Job whose file landed deletes it
|
||||
// (handleInternalFileUploadLanded).
|
||||
func (a *API) handleCommitFileUpload(w http.ResponseWriter, r *http.Request) {
|
||||
name, ok := a.authorizeFileOp(w, r)
|
||||
if !ok {
|
||||
@@ -335,15 +379,18 @@ func opView(op fileedit.OpState) fileOpView {
|
||||
}
|
||||
|
||||
// opError maps a failed op onto the API's codes. A Job that printed no result
|
||||
// carries only its condition's reason (DeadlineExceeded, BackoffLimitExceeded):
|
||||
// carries only its reason (DeadlineExceeded, BackoffLimitExceeded, OOMKilled):
|
||||
// the log it left is the world's content and the runtime's, and none of it is
|
||||
// the caller's to read.
|
||||
func opError(op fileedit.OpState) *fileOpError {
|
||||
res := op.Result
|
||||
if res == nil {
|
||||
msg := "the file operation stopped before it could report how it went (%s); run it again"
|
||||
if op.Reason == "DeadlineExceeded" {
|
||||
switch op.Reason {
|
||||
case "DeadlineExceeded":
|
||||
msg = "the file operation ran out of time (%s); run it again"
|
||||
case fileedit.ReasonOOMKilled:
|
||||
msg = "the file operation ran out of memory (%s); an archive of this many files has to be split into smaller ones"
|
||||
}
|
||||
return &fileOpError{Code: "job_failed", Message: fmt.Sprintf(msg, op.Reason)}
|
||||
}
|
||||
@@ -434,6 +481,8 @@ func writeFileSessionError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
case errors.Is(err, fileedit.ErrPartTooLarge):
|
||||
writeError(w, r, newError(http.StatusRequestEntityTooLarge, "part_too_large",
|
||||
"the part is larger than part_max_bytes, or runs past the size the upload began with"))
|
||||
case errors.Is(err, fileedit.ErrDigestMismatch):
|
||||
writeError(w, r, errDigestMismatch())
|
||||
case errors.Is(err, fileedit.ErrShortUpload):
|
||||
writeError(w, r, newError(http.StatusBadRequest, "upload_incomplete",
|
||||
"the part ended before its Content-Length; read where the upload stands and send it again"))
|
||||
|
||||
@@ -56,10 +56,19 @@ func sessionAnswer(t *testing.T, w *httptest.ResponseRecorder) fileSessionView {
|
||||
}
|
||||
|
||||
// doPart sends one part with the Content-Length given, whatever the body's own
|
||||
// length: -1 sends none, and one past the body is a part cut short.
|
||||
// length: -1 sends none, and one past the body is a part cut short. Its
|
||||
// Content-Digest is that of the body.
|
||||
func doPart(h http.Handler, target, body string, length int64) *httptest.ResponseRecorder {
|
||||
return doPartDigest(h, target, body, length, contentDigestOf(body))
|
||||
}
|
||||
|
||||
// doPartDigest is doPart with the Content-Digest given; empty sends none.
|
||||
func doPartDigest(h http.Handler, target, body string, length int64, digest string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("PUT", target, strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/octet-stream")
|
||||
if digest != "" {
|
||||
r.Header.Set("Content-Digest", digest)
|
||||
}
|
||||
r.ContentLength = length
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
@@ -108,7 +117,10 @@ func TestFileUploadSession(t *testing.T) {
|
||||
h := api.ExternalHandler()
|
||||
|
||||
s := beginSession(t, api, 10)
|
||||
if len(s.ID) != 32 || s.Path != sessionPath || s.Size != 10 || s.Received != 0 || s.PartMaxBytes != fileedit.PartBytes {
|
||||
// A new session lists its parts as [], which decodes non-nil; null would
|
||||
// leave a client resuming with nothing to walk.
|
||||
if len(s.ID) != 32 || s.Path != sessionPath || s.Size != 10 || s.Received != 0 || s.PartMaxBytes != fileedit.PartBytes ||
|
||||
s.Parts == nil || len(s.Parts) != 0 {
|
||||
t.Fatalf("begin = %+v", s)
|
||||
}
|
||||
|
||||
@@ -129,8 +141,10 @@ func TestFileUploadSession(t *testing.T) {
|
||||
t.Fatalf("early commit: code = %d calls = %d acquired %v (%s)", w.Code, files.calls, cl.acquired, w.Body.String())
|
||||
}
|
||||
|
||||
if w := status(api, s.ID); w.Code != http.StatusOK || sessionAnswer(t, w) != (fileSessionView{
|
||||
ID: s.ID, Path: sessionPath, Size: 10, Received: 5, PartMaxBytes: fileedit.PartBytes}) {
|
||||
hello := sha256.Sum256([]byte("hello"))
|
||||
if w := status(api, s.ID); w.Code != http.StatusOK || !reflect.DeepEqual(sessionAnswer(t, w), fileSessionView{
|
||||
ID: s.ID, Path: sessionPath, Size: 10, Received: 5, PartMaxBytes: fileedit.PartBytes,
|
||||
Parts: []filePartView{{Size: 5, SHA256: hex.EncodeToString(hello[:])}}}) {
|
||||
t.Fatalf("status: code = %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if w := doPart(h, partAt(s.ID, 5), "world", 5); w.Code != http.StatusOK || sessionAnswer(t, w).Received != 10 {
|
||||
@@ -172,11 +186,31 @@ func TestFileUploadSession(t *testing.T) {
|
||||
if again := fetchStaged(api, src); again.Code != http.StatusNotFound {
|
||||
t.Fatalf("second fetch: code = %d", again.Code)
|
||||
}
|
||||
// Served whole, so the session is gone and its file with it.
|
||||
if w := status(api, s.ID); w.Code != http.StatusNotFound || decodeErr(t, w) != "upload_not_found" {
|
||||
// Served whole, the session stays until the Job says the file landed: a
|
||||
// Job that fails after its fetch leaves the upload to be committed again.
|
||||
if w := status(api, s.ID); w.Code != http.StatusOK || sessionAnswer(t, w).Received != 10 {
|
||||
t.Fatalf("status after the fetch: code = %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
landed := func(token string) *httptest.ResponseRecorder {
|
||||
return do(api.InternalHandler(), "DELETE", strings.TrimPrefix(src.URL, api.InternalBaseURL), "",
|
||||
map[string]string{"Authorization": "Bearer " + token})
|
||||
}
|
||||
if w := landed(strings.Repeat("0", len(src.Token))); w.Code != http.StatusNotFound || decodeErr(t, w) != "not_found" {
|
||||
t.Fatalf("landed with the wrong token: code = %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if w := status(api, s.ID); w.Code != http.StatusOK {
|
||||
t.Fatalf("a wrong token let go of the session: code = %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if w := landed(src.Token); w.Code != http.StatusNoContent {
|
||||
t.Fatalf("landed: code = %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if w := status(api, s.ID); w.Code != http.StatusNotFound || decodeErr(t, w) != "upload_not_found" {
|
||||
t.Fatalf("status after it landed: code = %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
stageEmpty(t, api)
|
||||
if w := landed(src.Token); w.Code != http.StatusNotFound {
|
||||
t.Fatalf("landed twice: code = %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a Job that never fetched is committed again with a fresh token", func(t *testing.T) {
|
||||
@@ -260,6 +294,32 @@ func TestFileUploadSession(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a part changed on the way is refused and taken back", func(t *testing.T) {
|
||||
api, _, _, _ := mkFiles(t)
|
||||
api.External = staticExternal{p: fileOwner}
|
||||
h := api.ExternalHandler()
|
||||
s := beginSession(t, api, 10)
|
||||
doPart(h, partAt(s.ID, 0), "hello", 5)
|
||||
for _, c := range []struct {
|
||||
name, digest, errCode string
|
||||
}{
|
||||
{"another part's digest", contentDigestOf("wor1d"), "digest_mismatch"},
|
||||
{"no digest", "", "digest_required"},
|
||||
{"a malformed digest", "sha-256=:bm90IGEgc3VtCg==:", "bad_digest"},
|
||||
} {
|
||||
w := doPartDigest(h, partAt(s.ID, 5), "world", 5, c.digest)
|
||||
if w.Code != http.StatusBadRequest || decodeErr(t, w) != c.errCode {
|
||||
t.Fatalf("%s: code = %d (%s), want 400 %s", c.name, w.Code, w.Body.String(), c.errCode)
|
||||
}
|
||||
if got := sessionAnswer(t, status(api, s.ID)); got.Received != 5 || len(got.Parts) != 1 {
|
||||
t.Fatalf("%s: session after the refused part = %+v", c.name, got)
|
||||
}
|
||||
}
|
||||
if w := doPart(h, partAt(s.ID, 5), "world", 5); w.Code != http.StatusOK || sessionAnswer(t, w).Received != 10 {
|
||||
t.Fatalf("the part sent again: code = %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a part cut short leaves the session where it was", func(t *testing.T) {
|
||||
api, _, _, _ := mkFiles(t)
|
||||
api.External = staticExternal{p: fileOwner}
|
||||
@@ -283,6 +343,7 @@ func TestFileUploadSession(t *testing.T) {
|
||||
go func() {
|
||||
r := httptest.NewRequest("PUT", partAt(s.ID, 0), pr)
|
||||
r.Header.Set("Content-Type", "application/octet-stream")
|
||||
r.Header.Set("Content-Digest", contentDigestOf("abc"))
|
||||
r.ContentLength = 3
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
@@ -432,6 +493,8 @@ func TestFileUploadSession(t *testing.T) {
|
||||
{"a path leaving the world", sessionsRoute + "?path=../a.jar", `{"size":3}`, nil, http.StatusBadRequest, "bad_path"},
|
||||
{"an absolute path", sessionsRoute + "?path=/etc/a.jar", `{"size":3}`, nil, http.StatusBadRequest, "bad_path"},
|
||||
{"the world folder itself", sessionsRoute + "?path=plugins/..", `{"size":3}`, nil, http.StatusBadRequest, "bad_path"},
|
||||
{"a name longer than a folder entry holds", sessionsRoute + "?path=plugins/" + strings.Repeat("n", fileedit.NameMax-3) + ".jar", `{"size":3}`,
|
||||
nil, http.StatusBadRequest, "bad_path"},
|
||||
{"a staging disk at its floor", sessionsRoute + "?path=a.jar", `{"size":3}`,
|
||||
func(a *API) { a.FileStage.MinFree = 1 }, http.StatusInsufficientStorage, "upload_staging_full"},
|
||||
{"no stage", sessionsRoute + "?path=a.jar", `{"size":3}`,
|
||||
@@ -459,6 +522,19 @@ func TestFileUploadSession(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a name as long as a folder entry holds is taken", func(t *testing.T) {
|
||||
api, _, _, _ := mkFiles(t)
|
||||
api.External = staticExternal{p: fileOwner}
|
||||
long := strings.Repeat("n", fileedit.NameMax-4) + ".jar"
|
||||
w := do(api.ExternalHandler(), "POST", sessionsRoute+"?path=plugins/"+long, `{"size":3}`, jsonHeader)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("code = %d (%s), want 201", w.Code, w.Body.String())
|
||||
}
|
||||
if s := sessionAnswer(t, w); s.Path != "plugins/"+long {
|
||||
t.Fatalf("path = %q", s.Path)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a fifth upload at once -> 429", func(t *testing.T) {
|
||||
api, _, _, _ := mkFiles(t)
|
||||
api.External = staticExternal{p: fileOwner}
|
||||
@@ -686,7 +762,9 @@ func TestFileOps(t *testing.T) {
|
||||
deadline.Reason = "DeadlineExceeded"
|
||||
killed := base("i", fileedit.OpUpload, fileedit.OpFailed)
|
||||
killed.Reason = "BackoffLimitExceeded"
|
||||
files.ops = []fileedit.OpState{running, unzipped, uploaded, exists, full, changed, unsafe, deadline, killed}
|
||||
oom := base("j", fileedit.OpUnzip, fileedit.OpFailed)
|
||||
oom.Reason = "OOMKilled"
|
||||
files.ops = []fileedit.OpState{running, unzipped, uploaded, exists, full, changed, unsafe, deadline, killed, oom}
|
||||
|
||||
w := list(api)
|
||||
if w.Code != http.StatusOK || files.gotServer != "survival" {
|
||||
@@ -725,6 +803,8 @@ func TestFileOps(t *testing.T) {
|
||||
"the file operation ran out of time (DeadlineExceeded); run it again", nil)),
|
||||
op("i", "upload", "failed", failure("job_failed",
|
||||
"the file operation stopped before it could report how it went (BackoffLimitExceeded); run it again", nil)),
|
||||
op("j", "unzip", "failed", failure("job_failed",
|
||||
"the file operation ran out of memory (OOMKilled); an archive of this many files has to be split into smaller ones", nil)),
|
||||
}}
|
||||
if got := fileAnswer(t, w); !reflect.DeepEqual(got, want) {
|
||||
gotJSON, _ := json.MarshalIndent(got, "", " ")
|
||||
|
||||
+105
-10
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -109,9 +111,14 @@ func (a *API) handleListFiles(w http.ResponseWriter, r *http.Request) {
|
||||
ls.Entries = []fileedit.Entry{} // an empty directory is [], never null
|
||||
}
|
||||
// free_bytes lets the panel refuse an upload the volume cannot take before
|
||||
// sending any of it; the Job that lands it checks again.
|
||||
// sending any of it; the Job that lands it checks again. It is null when the
|
||||
// Job could not read it, so a full volume (0) is never mistaken for that.
|
||||
var free any = ls.Free
|
||||
if ls.Free < 0 {
|
||||
free = nil
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"path": path, "entries": ls.Entries, "truncated": ls.Truncated, "free_bytes": ls.Free,
|
||||
"path": path, "entries": ls.Entries, "truncated": ls.Truncated, "free_bytes": free,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -196,8 +203,10 @@ func (a *API) handleWriteFile(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// A write holds the world volume for its Job's lifetime (internal/maintenance);
|
||||
// reads and listings do not, since a read-only mount cannot hurt a server
|
||||
// starting beside it.
|
||||
// reads and listings do not. A read-only mount cannot hurt a server starting
|
||||
// beside it, and a read that overlaps a restore or another change can at worst
|
||||
// show a file mid-change: the sha256 it returned then no longer matches, so a
|
||||
// save built on it is refused with file_changed.
|
||||
release, ok := a.acquireWorld(w, r, name, maintenance.KindFileWrite, "stop the server before editing its files")
|
||||
if !ok {
|
||||
return
|
||||
@@ -335,7 +344,10 @@ func (a *API) handleRenameFile(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// Content-Length is required (411 length_required): the stage reserves room for
|
||||
// the declared size before a byte is written, and a size promised up front is
|
||||
// what lets a short body be told from a whole one.
|
||||
// what lets a short body be told from a whole one. So is Content-Digest, the
|
||||
// SHA-256 the client computed over the body (contentDigest): bytes that arrive
|
||||
// hashing to anything else are refused with 400 digest_mismatch, and the client
|
||||
// sends them again.
|
||||
func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
name, ok := a.authorizeFileOp(w, r)
|
||||
if !ok {
|
||||
@@ -345,6 +357,10 @@ func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !namesFit(path) {
|
||||
writeError(w, r, errNameTooLong())
|
||||
return
|
||||
}
|
||||
if a.FileStage == nil || a.InternalBaseURL == "" {
|
||||
writeError(w, r, newError(http.StatusServiceUnavailable, "files_unavailable",
|
||||
"uploads are not configured"))
|
||||
@@ -361,10 +377,17 @@ func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
r.ContentLength, fileedit.MaxUploadBytes))
|
||||
return
|
||||
}
|
||||
want, ok := contentDigest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
overwrite := r.URL.Query().Get("overwrite") == "true"
|
||||
|
||||
staged, drop, err := a.FileStage.Put(r.Body, r.ContentLength)
|
||||
staged, drop, err := a.FileStage.Put(r.Body, r.ContentLength, want)
|
||||
switch {
|
||||
case errors.Is(err, fileedit.ErrDigestMismatch):
|
||||
writeError(w, r, errDigestMismatch())
|
||||
return
|
||||
case errors.Is(err, fileedit.ErrStageFull):
|
||||
writeError(w, r, newError(http.StatusInsufficientStorage, "upload_staging_full",
|
||||
"felis has no room to take this upload right now; try again later or ask an admin"))
|
||||
@@ -429,11 +452,83 @@ func (a *API) handleInternalFileUpload(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
// A session sent whole is on the Job's side now; one cut short stays, so
|
||||
// committing it again does not mean sending it again.
|
||||
if n, err := io.Copy(w, f); err == nil && n == size {
|
||||
a.FileStage.Served(r.PathValue("id"))
|
||||
_, _ = io.Copy(w, f)
|
||||
}
|
||||
|
||||
// handleInternalFileUploadLanded serves DELETE
|
||||
// /api/v1/internal/file-uploads/{id} — the Job that fetched a file sent in
|
||||
// parts reports it landed, with the token it fetched it by, and the staged copy
|
||||
// is deleted. Until then the copy stays: a Job that failed after its fetch (the
|
||||
// digest did not match, the volume filled, a file was in the way) is started
|
||||
// again by committing again, without the file being sent again. Public on the
|
||||
// internal face like the fetch, with the same token as the check; a token that
|
||||
// does not open the upload, or an unknown id, is 404.
|
||||
func (a *API) handleInternalFileUploadLanded(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if a.FileStage == nil || !ok || a.FileStage.Landed(r.PathValue("id"), token) != nil {
|
||||
writeError(w, r, newError(http.StatusNotFound, "not_found", "no such upload"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// contentDigest reads the SHA-256 a client computed over the body it sends, from
|
||||
// its Content-Digest header (parseContentDigest). An upload without one is
|
||||
// refused (400 digest_required): felis-api could not otherwise tell bytes
|
||||
// changed on the way from the bytes that were sent. It writes the refusal itself
|
||||
// and reports false.
|
||||
func contentDigest(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
|
||||
sum, err := parseContentDigest(r.Header.Values("Content-Digest"))
|
||||
switch {
|
||||
case errors.Is(err, errNoContentDigest):
|
||||
writeError(w, r, newError(http.StatusBadRequest, "digest_required",
|
||||
"send the SHA-256 of the body as Content-Digest: sha-256=:<base64>:"))
|
||||
return nil, false
|
||||
case err != nil:
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_digest",
|
||||
"Content-Digest must carry sha-256=:<base64 of the 32-byte SHA-256>:"))
|
||||
return nil, false
|
||||
}
|
||||
return sum, true
|
||||
}
|
||||
|
||||
var (
|
||||
errNoContentDigest = errors.New("no sha-256 Content-Digest")
|
||||
errBadContentDigest = errors.New("a malformed sha-256 Content-Digest")
|
||||
)
|
||||
|
||||
// parseContentDigest finds the SHA-256 in the values of a Content-Digest field
|
||||
// (RFC 9530): sha-256=:<base64>:, among any other algorithms it lists, which
|
||||
// are ignored. errNoContentDigest when there is none, errBadContentDigest when
|
||||
// it is not 32 bytes of base64 between colons.
|
||||
func parseContentDigest(values []string) ([]byte, error) {
|
||||
var found []byte
|
||||
for _, v := range values {
|
||||
for _, member := range strings.Split(v, ",") {
|
||||
key, value, _ := strings.Cut(member, "=")
|
||||
if !strings.EqualFold(strings.TrimSpace(key), "sha-256") {
|
||||
continue
|
||||
}
|
||||
value, _, _ = strings.Cut(value, ";") // parameters
|
||||
value = strings.TrimSpace(value)
|
||||
inner, pre := strings.CutPrefix(value, ":")
|
||||
inner, post := strings.CutSuffix(inner, ":")
|
||||
sum, err := base64.StdEncoding.DecodeString(inner)
|
||||
if !pre || !post || err != nil || len(sum) != sha256.Size {
|
||||
return nil, errBadContentDigest
|
||||
}
|
||||
found = sum
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
return nil, errNoContentDigest
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func errDigestMismatch() error {
|
||||
return newError(http.StatusBadRequest, "digest_mismatch",
|
||||
"the bytes that arrived do not match their Content-Digest, so they were changed on the way; send them again")
|
||||
}
|
||||
|
||||
// auditFile records a file change. The target is "<server>:<path>", as file.write
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -125,12 +126,12 @@ func (f *fakeFileEditor) Ops(_ context.Context, server string) ([]fileedit.OpSta
|
||||
return f.ops, f.err
|
||||
}
|
||||
|
||||
// fileRouteHeader is the Content-Type a file route's body goes with: raw bytes
|
||||
// for an upload, JSON for any other body.
|
||||
// fileRouteHeader is the headers a file route's body goes with: raw bytes and
|
||||
// their Content-Digest for an upload, JSON for any other body.
|
||||
func fileRouteHeader(name, body string) map[string]string {
|
||||
switch {
|
||||
case name == "upload":
|
||||
return ctHeader("application/octet-stream")
|
||||
return map[string]string{"Content-Type": "application/octet-stream", "Content-Digest": contentDigestOf(body)}
|
||||
case body != "":
|
||||
return jsonHeader
|
||||
}
|
||||
@@ -397,6 +398,21 @@ func TestFileEditorHandlers(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a full volume lists 0 free and one the Job could not measure null", func(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
free int64
|
||||
want string
|
||||
}{{0, `"free_bytes":0`}, {-1, `"free_bytes":null`}} {
|
||||
api, _, _, files := mkFiles(t)
|
||||
files.free = c.free
|
||||
api.External = staticExternal{p: owner}
|
||||
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/files?path=config", "", nil)
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), c.want) {
|
||||
t.Fatalf("free %d: code = %d body %s, want %s", c.free, w.Code, w.Body.String(), c.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list without a path lists the world root", func(t *testing.T) {
|
||||
api, _, _, files := mkFiles(t)
|
||||
api.External = staticExternal{p: owner}
|
||||
@@ -744,11 +760,19 @@ func TestFileManagerHandlers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// contentDigestOf is the Content-Digest a client sends with body.
|
||||
func contentDigestOf(body string) string {
|
||||
sum := sha256.Sum256([]byte(body))
|
||||
return "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":"
|
||||
}
|
||||
|
||||
// doUpload sends an upload whose Content-Length is declared, not measured, the
|
||||
// way a client that streams or lies would send it.
|
||||
// way a client that streams or lies would send it. Its Content-Digest is that
|
||||
// of the body.
|
||||
func doUpload(h http.Handler, body string, length int64) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("PUT", "/api/v1/servers/survival/files/upload?path=plugins/x.jar", strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/octet-stream")
|
||||
r.Header.Set("Content-Digest", contentDigestOf(body))
|
||||
r.ContentLength = length
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
@@ -777,7 +801,7 @@ func TestFileUpload(t *testing.T) {
|
||||
digest := sha256.Sum256([]byte(body))
|
||||
sum := hex.EncodeToString(digest[:])
|
||||
const route = "/api/v1/servers/survival/files/upload?path=plugins/x.jar"
|
||||
octet := ctHeader("application/octet-stream")
|
||||
octet := map[string]string{"Content-Type": "application/octet-stream", "Content-Digest": contentDigestOf(body)}
|
||||
|
||||
t.Run("the Job fetches the body once, with its token alone", func(t *testing.T) {
|
||||
api, repo, _, files := mkFiles(t)
|
||||
@@ -785,7 +809,7 @@ func TestFileUpload(t *testing.T) {
|
||||
// Every other internal route wants a service token; the Job has none.
|
||||
api.Internal = CallerTokens{CallerVelocity: "s3cr3t"}
|
||||
prefix := api.InternalBaseURL + "/api/v1/internal/file-uploads/"
|
||||
var bare, wrong, unschemed, fetched, again *httptest.ResponseRecorder
|
||||
var bare, wrong, unschemed, fetched, again, landedWrong, landed *httptest.ResponseRecorder
|
||||
files.onUpload = func(src fileedit.UploadSource) {
|
||||
id, ok := strings.CutPrefix(src.URL, prefix)
|
||||
if !ok || len(id) != 32 {
|
||||
@@ -799,6 +823,10 @@ func TestFileUpload(t *testing.T) {
|
||||
unschemed = do(h, "GET", at, "", map[string]string{"Authorization": src.Token})
|
||||
fetched = do(h, "GET", at, "", map[string]string{"Authorization": "Bearer " + src.Token})
|
||||
again = do(h, "GET", at, "", map[string]string{"Authorization": "Bearer " + src.Token})
|
||||
// The Job reports it landed. A single upload goes when its request
|
||||
// ends, so the report takes nothing away early.
|
||||
landedWrong = do(h, "DELETE", at, "", map[string]string{"Authorization": "Bearer " + strings.Repeat("0", len(src.Token))})
|
||||
landed = do(h, "DELETE", at, "", map[string]string{"Authorization": "Bearer " + src.Token})
|
||||
}
|
||||
|
||||
w := do(api.ExternalHandler(), "PUT", route, body, octet)
|
||||
@@ -810,6 +838,7 @@ func TestFileUpload(t *testing.T) {
|
||||
}
|
||||
for name, r := range map[string]*httptest.ResponseRecorder{
|
||||
"no token": bare, "wrong token": wrong, "the token without Bearer": unschemed, "second fetch": again,
|
||||
"a landed report with the wrong token": landedWrong,
|
||||
} {
|
||||
if r.Code != http.StatusNotFound || decodeErr(t, r) != "not_found" {
|
||||
t.Errorf("%s: code = %d (%s), want 404 not_found", name, r.Code, r.Body.String())
|
||||
@@ -819,6 +848,9 @@ func TestFileUpload(t *testing.T) {
|
||||
fetched.Header().Get("Content-Length") != strconv.Itoa(len(body)) {
|
||||
t.Fatalf("fetch: code = %d, %q, Content-Length %q", fetched.Code, fetched.Body.String(), fetched.Header().Get("Content-Length"))
|
||||
}
|
||||
if landed.Code != http.StatusNoContent {
|
||||
t.Fatalf("landed: code = %d (%s)", landed.Code, landed.Body.String())
|
||||
}
|
||||
if files.gotPath != "plugins/x.jar" || files.gotSource.Size != int64(len(body)) ||
|
||||
files.gotSource.SHA256 != sum || files.gotOverwrite {
|
||||
t.Fatalf("executor saw path %q, source %+v, overwrite %v", files.gotPath, files.gotSource, files.gotOverwrite)
|
||||
@@ -868,6 +900,51 @@ func TestFileUpload(t *testing.T) {
|
||||
stageEmpty(t, api)
|
||||
})
|
||||
|
||||
t.Run("the body comes with its SHA-256, checked before anything is asked of the world", func(t *testing.T) {
|
||||
other := sha256.Sum256([]byte("PK\x03\x04 another jar"))
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
digest []string
|
||||
code int
|
||||
errCode string
|
||||
}{
|
||||
{"none", nil, http.StatusBadRequest, "digest_required"},
|
||||
{"only another algorithm", []string{"sha-512=:" + base64.StdEncoding.EncodeToString(make([]byte, 64)) + ":"}, http.StatusBadRequest, "digest_required"},
|
||||
{"hex in place of base64", []string{"sha-256=:" + sum + ":"}, http.StatusBadRequest, "bad_digest"},
|
||||
{"a bare base64 value", []string{strings.Trim(strings.TrimPrefix(contentDigestOf(body), "sha-256="), ":")}, http.StatusBadRequest, "digest_required"},
|
||||
{"no closing colon", []string{strings.TrimSuffix(contentDigestOf(body), ":")}, http.StatusBadRequest, "bad_digest"},
|
||||
{"base64 without colons", []string{"sha-256=" + strings.Trim(strings.TrimPrefix(contentDigestOf(body), "sha-256="), ":")}, http.StatusBadRequest, "bad_digest"},
|
||||
{"31 bytes", []string{"sha-256=:" + base64.StdEncoding.EncodeToString(digest[:31]) + ":"}, http.StatusBadRequest, "bad_digest"},
|
||||
{"another body's", []string{"sha-256=:" + base64.StdEncoding.EncodeToString(other[:]) + ":"}, http.StatusBadRequest, "digest_mismatch"},
|
||||
{"the right one, then a wrong one", []string{contentDigestOf(body), "sha-256=:" + base64.StdEncoding.EncodeToString(other[:]) + ":"}, http.StatusBadRequest, "digest_mismatch"},
|
||||
{"among others, upper case, with a parameter", []string{"sha-512=:" + base64.StdEncoding.EncodeToString(make([]byte, 64)) + ":, SHA-256=" + strings.TrimPrefix(contentDigestOf(body), "sha-256=") + ";p=1"}, http.StatusOK, ""},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
api, repo, cl, files := mkFiles(t)
|
||||
api.External = staticExternal{p: owner}
|
||||
r := httptest.NewRequest("PUT", route, strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/octet-stream")
|
||||
for _, v := range c.digest {
|
||||
r.Header.Add("Content-Digest", v)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
api.ExternalHandler().ServeHTTP(w, r)
|
||||
recordContract(r, body, w)
|
||||
if c.code == http.StatusOK {
|
||||
if w.Code != http.StatusOK || files.calls != 1 || files.gotSource.SHA256 != sum {
|
||||
t.Fatalf("code = %d calls = %d source %+v (%s)", w.Code, files.calls, files.gotSource, w.Body.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
if w.Code != c.code || decodeErr(t, w) != c.errCode || files.calls != 0 || len(cl.acquired) != 0 || len(repo.audits) != 0 {
|
||||
t.Fatalf("code = %d calls = %d acquired %v audits %d (%s), want %d %s",
|
||||
w.Code, files.calls, cl.acquired, len(repo.audits), w.Body.String(), c.code, c.errCode)
|
||||
}
|
||||
stageEmpty(t, api)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no Content-Length -> 411", func(t *testing.T) {
|
||||
api, _, _, files := mkFiles(t)
|
||||
api.External = staticExternal{p: owner}
|
||||
@@ -887,6 +964,28 @@ func TestFileUpload(t *testing.T) {
|
||||
stageEmpty(t, api)
|
||||
})
|
||||
|
||||
t.Run("a name longer than a folder entry holds -> 400 before a byte is staged", func(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
code int
|
||||
errCode string
|
||||
}{
|
||||
{strings.Repeat("n", fileedit.NameMax-3) + ".jar", http.StatusBadRequest, "bad_path"},
|
||||
{strings.Repeat("n", fileedit.NameMax-4) + ".jar", http.StatusOK, ""},
|
||||
} {
|
||||
api, _, _, files := mkFiles(t)
|
||||
api.External = staticExternal{p: owner}
|
||||
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/files/upload?path=plugins/"+c.name, body, octet)
|
||||
if w.Code != c.code || (c.errCode != "" && decodeErr(t, w) != c.errCode) {
|
||||
t.Fatalf("%d-byte name: code = %d (%s), want %d", len(c.name), w.Code, w.Body.String(), c.code)
|
||||
}
|
||||
if wantCalls := map[bool]int{true: 1, false: 0}[c.code == http.StatusOK]; files.calls != wantCalls {
|
||||
t.Fatalf("%d-byte name: executor calls = %d, want %d", len(c.name), files.calls, wantCalls)
|
||||
}
|
||||
stageEmpty(t, api)
|
||||
}
|
||||
})
|
||||
|
||||
// Staged, and so short: the body is a few bytes of a declared 64 MiB.
|
||||
t.Run("a declared size at the cap is taken", func(t *testing.T) {
|
||||
api, _, _, files := mkFiles(t)
|
||||
@@ -935,10 +1034,12 @@ func TestFileUpload(t *testing.T) {
|
||||
t.Run("the internal route without a stage -> 404", func(t *testing.T) {
|
||||
api, _, _, _ := mkFiles(t)
|
||||
api.FileStage = nil
|
||||
w := do(api.InternalHandler(), "GET", "/api/v1/internal/file-uploads/00112233445566778899aabbccddeeff", "",
|
||||
map[string]string{"Authorization": "Bearer t"})
|
||||
if w.Code != http.StatusNotFound || decodeErr(t, w) != "not_found" {
|
||||
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
|
||||
for _, method := range []string{"GET", "DELETE"} {
|
||||
w := do(api.InternalHandler(), method, "/api/v1/internal/file-uploads/00112233445566778899aabbccddeeff", "",
|
||||
map[string]string{"Authorization": "Bearer t"})
|
||||
if w.Code != http.StatusNotFound || decodeErr(t, w) != "not_found" {
|
||||
t.Fatalf("%s: code = %d (%s)", method, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -179,6 +179,9 @@ func TestLatestJobsExplainsFailures(t *testing.T) {
|
||||
}}},
|
||||
}
|
||||
}
|
||||
// Killed for memory mid-walk: the last line it printed says nothing of that.
|
||||
oom := pod("c-1", "backup-survival-c", 4, 137, "archiving survival\n")
|
||||
oom.Status.ContainerStatuses[0].State.Terminated.Reason = "OOMKilled"
|
||||
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(
|
||||
failedJob("backup-survival-a", 1),
|
||||
failedJob("backup-survival-b", 2),
|
||||
@@ -186,6 +189,7 @@ func TestLatestJobsExplainsFailures(t *testing.T) {
|
||||
pod("a-2", "backup-survival-a", 3, 1,
|
||||
"archiving survival\nfelis backup: backup: not enough free disk for the archive: the world is 2.0 GiB\n"),
|
||||
pod("b-1", "backup-survival-b", 2, 0, "done"),
|
||||
failedJob("backup-survival-c", 4), oom,
|
||||
).Build()
|
||||
|
||||
jobs, err := NewK8sJobStatus(c, "minecraft").LatestJobs(context.Background(), "survival")
|
||||
@@ -202,4 +206,7 @@ func TestLatestJobsExplainsFailures(t *testing.T) {
|
||||
if want := "Job has reached the specified backoff limit"; got["backup-survival-b"] != want {
|
||||
t.Errorf("b: message = %q, want the condition text", got["backup-survival-b"])
|
||||
}
|
||||
if want := "the job ran out of memory and the system stopped it (OOMKilled)"; got["backup-survival-c"] != want {
|
||||
t.Errorf("c: message = %q, want %q", got["backup-survival-c"], want)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/fileedit"
|
||||
"felis.lolicon.best/internal/maintenance"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
@@ -107,14 +108,23 @@ func (k *K8sJobStatus) explainFailures(ctx context.Context, serverName string, j
|
||||
}
|
||||
}
|
||||
|
||||
// outOfMemoryLine is a failed Job's message when the kernel killed it for
|
||||
// memory. The reason in brackets is what the panel knows it by.
|
||||
const outOfMemoryLine = "the job ran out of memory and the system stopped it (OOMKilled)"
|
||||
|
||||
// lastTerminationLine returns the last non-empty line of the pod's terminated
|
||||
// container message, capped for display.
|
||||
// container message, capped for display. A container the kernel killed for
|
||||
// going over its memory limit printed nothing about it, so that is said instead
|
||||
// of whatever line it happened to print last.
|
||||
func lastTerminationLine(pod *corev1.Pod) string {
|
||||
for _, cs := range pod.Status.ContainerStatuses {
|
||||
t := cs.State.Terminated
|
||||
if t == nil || t.ExitCode == 0 {
|
||||
continue
|
||||
}
|
||||
if t.Reason == fileedit.ReasonOOMKilled {
|
||||
return outOfMemoryLine
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(t.Message), "\n")
|
||||
line := strings.TrimSpace(lines[len(lines)-1])
|
||||
if len(line) > 400 {
|
||||
|
||||
@@ -38,7 +38,7 @@ func maintenanceLabel(kind string) string {
|
||||
case maintenance.KindFileWrite:
|
||||
return "a file write"
|
||||
case maintenance.KindExport:
|
||||
return "a world export"
|
||||
return "a world export or file download"
|
||||
case maintenance.KindReap:
|
||||
return "the idle-world reaper"
|
||||
}
|
||||
|
||||
@@ -166,6 +166,10 @@ func (a *API) handleCreateSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
// caller does not own is reported as 404, so this endpoint cannot upload to — or
|
||||
// probe the existence of — another user's submission.
|
||||
//
|
||||
// The body carries its SHA-256 as Content-Digest (contentDigest); bytes that hash
|
||||
// to anything else were changed on the way, and none of them are kept
|
||||
// (submit.VerifyDigest).
|
||||
//
|
||||
// Uploading does not change the submission row (there is no "uploaded" column):
|
||||
// the blob store is the presence source of truth, which admin approval consults.
|
||||
func (a *API) handleUploadSubmissionContext(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -173,6 +177,10 @@ func (a *API) handleUploadSubmissionContext(w http.ResponseWriter, r *http.Reque
|
||||
writeError(w, r, errSubmissionsUnavailable)
|
||||
return
|
||||
}
|
||||
want, ok := contentDigest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p := principalFromContext(r.Context())
|
||||
// Reserve the per-user upload cooldown BEFORE streaming. The body is the
|
||||
// expensive part (up to the 1 GiB blob cap), so without a reservation the
|
||||
@@ -188,7 +196,7 @@ func (a *API) handleUploadSubmissionContext(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
defer release()
|
||||
id := r.PathValue("id")
|
||||
sub, err := a.Submissions.UploadContext(r.Context(), id, p.UserID, r.Body)
|
||||
sub, err := a.Submissions.UploadContext(r.Context(), id, p.UserID, submit.VerifyDigest(r.Body, want))
|
||||
if err != nil {
|
||||
writeSubmitError(w, r, err)
|
||||
return
|
||||
@@ -240,7 +248,8 @@ func (a *API) handleContextUploadStatus(w http.ResponseWriter, r *http.Request)
|
||||
// else must equal the staged length (409 upload_offset_mismatch otherwise). A
|
||||
// part is small enough for any edge, so no cooldown applies here: the staged
|
||||
// total is bounded by the context cap and the storage budget, and completion
|
||||
// holds the cooldown.
|
||||
// holds the cooldown. Each part carries its own Content-Digest, and one that
|
||||
// does not hash to it is cut back off, as a part that breaks off is.
|
||||
func (a *API) handleContextUploadPart(w http.ResponseWriter, r *http.Request) {
|
||||
if a.Submissions == nil {
|
||||
writeError(w, r, errSubmissionsUnavailable)
|
||||
@@ -252,8 +261,12 @@ func (a *API) handleContextUploadPart(w http.ResponseWriter, r *http.Request) {
|
||||
"offset must be the byte position the part starts at"))
|
||||
return
|
||||
}
|
||||
want, ok := contentDigest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p := principalFromContext(r.Context())
|
||||
prog, err := a.Submissions.UploadPart(r.Context(), r.PathValue("id"), p.UserID, offset, r.Body)
|
||||
prog, err := a.Submissions.UploadPart(r.Context(), r.PathValue("id"), p.UserID, offset, submit.VerifyDigest(r.Body, want))
|
||||
if err != nil {
|
||||
writeSubmitError(w, r, err)
|
||||
return
|
||||
@@ -557,6 +570,8 @@ func writeSubmitError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
case errors.Is(err, submit.ErrUploadBusy):
|
||||
writeError(w, r, newError(http.StatusConflict, "upload_busy",
|
||||
"another request is still writing this upload; read where it stands and continue from there"))
|
||||
case errors.Is(err, submit.ErrDigestMismatch):
|
||||
writeError(w, r, errDigestMismatch())
|
||||
case errors.As(err, &mismatch):
|
||||
writeError(w, r, newError(http.StatusConflict, "upload_offset_mismatch",
|
||||
"the upload holds %d bytes; send the part that starts there", mismatch.Received))
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestContextUploadPartForwardsOffsetBodyAndPrincipal(t *testing.T) {
|
||||
fs := &fakeSubmissions{progress: submit.UploadProgress{Received: 8, PartMaxBytes: 33554432, MaxContextBytes: 1073741824}}
|
||||
api := appSubAPI(fs)
|
||||
w := do(api.ExternalHandler(), "PUT", "/api/v1/me/submissions/sub-9/context/upload?offset=4", "abcd",
|
||||
ctHeader("application/octet-stream"))
|
||||
map[string]string{"Content-Type": "application/octet-stream", "Content-Digest": contentDigestOf("abcd")})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -72,7 +72,8 @@ func TestContextUploadErrors(t *testing.T) {
|
||||
503, "uploads_store_unavailable", "send the request again", "5"},
|
||||
} {
|
||||
fs := &fakeSubmissions{chunkErr: tc.err}
|
||||
w := do(appSubAPI(fs).ExternalHandler(), "PUT", "/api/v1/me/submissions/sub-9/context/upload?offset=12", "abcd", nil)
|
||||
w := do(appSubAPI(fs).ExternalHandler(), "PUT", "/api/v1/me/submissions/sub-9/context/upload?offset=12", "abcd",
|
||||
map[string]string{"Content-Digest": contentDigestOf("abcd")})
|
||||
if w.Code != tc.code || decodeErr(t, w) != tc.want {
|
||||
t.Errorf("%s: %d %s, want %d %s", tc.name, w.Code, w.Body.String(), tc.code, tc.want)
|
||||
}
|
||||
@@ -122,7 +123,8 @@ func TestContextUploadCompleteHoldsTheCooldownAndAudits(t *testing.T) {
|
||||
t.Fatalf("second completion in the window: %d %s, want 429 submission_cooldown", w.Code, w.Body.String())
|
||||
}
|
||||
// A part never waits on the cooldown.
|
||||
if w := do(eh, "PUT", "/api/v1/me/submissions/sub-9/context/upload?offset=0", "\x1f\x8b", nil); w.Code != http.StatusOK {
|
||||
if w := do(eh, "PUT", "/api/v1/me/submissions/sub-9/context/upload?offset=0", "\x1f\x8b",
|
||||
map[string]string{"Content-Digest": contentDigestOf("\x1f\x8b")}); w.Code != http.StatusOK {
|
||||
t.Fatalf("part inside the cooldown: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -141,3 +143,42 @@ func TestContextUploadWithoutServiceIs503(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A context upload, whole or in parts, carries the SHA-256 of its body: one
|
||||
// without it is refused before the lane sees it, and bytes that do not hash to
|
||||
// it are refused as changed on the way (the lane keeps none of them), so the
|
||||
// client sends them again.
|
||||
func TestContextUploadsCheckTheBodyDigest(t *testing.T) {
|
||||
body := "\x1f\x8b\x08\x00 the modpack bytes"
|
||||
for _, rq := range []struct{ name, method, target string }{
|
||||
{"a part", "PUT", "/api/v1/me/submissions/sub-9/context/upload?offset=0"},
|
||||
{"a whole context", "POST", "/api/v1/me/submissions/sub-9/context"},
|
||||
} {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
sent string
|
||||
headers map[string]string
|
||||
code int
|
||||
want string
|
||||
}{
|
||||
{"without a digest", body, nil, http.StatusBadRequest, "digest_required"},
|
||||
{"changed on the way", body[:len(body)-1] + "X", map[string]string{"Content-Digest": contentDigestOf(body)}, http.StatusBadRequest, "digest_mismatch"},
|
||||
{"as sent", body, map[string]string{"Content-Digest": contentDigestOf(body)}, http.StatusOK, ""},
|
||||
} {
|
||||
t.Run(rq.name+" "+tc.name, func(t *testing.T) {
|
||||
fs := &fakeSubmissions{}
|
||||
w := do(appSubAPI(fs).ExternalHandler(), rq.method, rq.target, tc.sent, tc.headers)
|
||||
if w.Code != tc.code {
|
||||
t.Fatalf("code = %d (%s), want %d", w.Code, w.Body.String(), tc.code)
|
||||
}
|
||||
if tc.want != "" && decodeErr(t, w) != tc.want {
|
||||
t.Fatalf("error = %s, want %s", w.Body.String(), tc.want)
|
||||
}
|
||||
reached := fs.chunkID != "" || fs.uploadedID != ""
|
||||
if reached != (tc.headers != nil) {
|
||||
t.Fatalf("the body reached the lane: %v, want %v", reached, tc.headers != nil)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,8 +73,13 @@ func (f *fakeSubmissions) UploadStatus(_ context.Context, id, submittedBy string
|
||||
|
||||
func (f *fakeSubmissions) UploadPart(_ context.Context, id, submittedBy string, offset int64, r io.Reader) (submit.UploadProgress, error) {
|
||||
f.chunkID, f.chunkBy, f.partOffset = id, submittedBy, offset
|
||||
b, _ := io.ReadAll(r)
|
||||
b, err := io.ReadAll(r)
|
||||
f.partBody = string(b)
|
||||
if err != nil {
|
||||
// A read that fails (a body changed on the way) keeps nothing, as in
|
||||
// PartStore.
|
||||
return submit.UploadProgress{}, err
|
||||
}
|
||||
return f.progress, f.chunkErr
|
||||
}
|
||||
|
||||
@@ -101,7 +106,10 @@ func (f *fakeSubmissions) UploadContext(_ context.Context, id, submittedBy strin
|
||||
if f.uploadErr != nil {
|
||||
return nil, f.uploadErr
|
||||
}
|
||||
n, _ := io.Copy(io.Discard, r)
|
||||
n, err := io.Copy(io.Discard, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.uploadedN = n
|
||||
return &submit.Submission{ID: id, SubmittedBy: submittedBy, Status: submit.StatusPendingReview}, nil
|
||||
}
|
||||
@@ -235,7 +243,7 @@ func TestUploadSubmissionContextStreamsBody(t *testing.T) {
|
||||
// A tiny gzip-magic-prefixed body stands in for a real context.tar.gz.
|
||||
body := "\x1f\x8b\x08\x00 the modpack bytes"
|
||||
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", body,
|
||||
map[string]string{"Content-Type": "application/gzip"})
|
||||
map[string]string{"Content-Type": "application/gzip", "Content-Digest": contentDigestOf(body)})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -255,7 +263,7 @@ func TestUploadSubmissionContextStreamsBody(t *testing.T) {
|
||||
func TestUploadSubmissionContextNotOwnedIs404(t *testing.T) {
|
||||
fs := &fakeSubmissions{uploadErr: submit.ErrNotFound}
|
||||
api := appSubAPI(fs)
|
||||
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-x/context", "\x1f\x8bdata", nil)
|
||||
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-x/context", "\x1f\x8bdata", map[string]string{"Content-Digest": contentDigestOf("\x1f\x8bdata")})
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d, want 404 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -265,7 +273,7 @@ func TestUploadSubmissionContextNotOwnedIs404(t *testing.T) {
|
||||
func TestUploadSubmissionContextAlreadyReviewedIs409(t *testing.T) {
|
||||
fs := &fakeSubmissions{uploadErr: submit.ErrAlreadyReviewed}
|
||||
api := appSubAPI(fs)
|
||||
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "\x1f\x8bdata", nil)
|
||||
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "\x1f\x8bdata", map[string]string{"Content-Digest": contentDigestOf("\x1f\x8bdata")})
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("code = %d, want 409 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -275,7 +283,7 @@ func TestUploadSubmissionContextAlreadyReviewedIs409(t *testing.T) {
|
||||
func TestUploadSubmissionContextBadFormatIs400(t *testing.T) {
|
||||
fs := &fakeSubmissions{uploadErr: fmt.Errorf("%w: build context must be a gzip-compressed tarball (.tar.gz)", submit.ErrInvalid)}
|
||||
api := appSubAPI(fs)
|
||||
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "not gzip", nil)
|
||||
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "not gzip", map[string]string{"Content-Digest": contentDigestOf("not gzip")})
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -289,7 +297,7 @@ func TestUploadSubmissionContextBadFormatIs400(t *testing.T) {
|
||||
func TestUploadSubmissionContextNoTransportIs503(t *testing.T) {
|
||||
fs := &fakeSubmissions{uploadErr: submit.ErrUploadsUnavailable}
|
||||
api := appSubAPI(fs)
|
||||
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "\x1f\x8bdata", nil)
|
||||
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "\x1f\x8bdata", map[string]string{"Content-Digest": contentDigestOf("\x1f\x8bdata")})
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("code = %d, want 503 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -303,7 +311,7 @@ func TestUploadSubmissionContextNoTransportIs503(t *testing.T) {
|
||||
func TestUploadSubmissionContextWithoutServiceIs503(t *testing.T) {
|
||||
app := appSubAPI(nil)
|
||||
app.Submissions = nil
|
||||
w := do(app.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "\x1f\x8bdata", nil)
|
||||
w := do(app.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "\x1f\x8bdata", map[string]string{"Content-Digest": contentDigestOf("\x1f\x8bdata")})
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("code = %d, want 503 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -871,7 +879,7 @@ func TestSubmissionQuotaIs403(t *testing.T) {
|
||||
})
|
||||
t.Run("upload", func(t *testing.T) {
|
||||
fs := &fakeSubmissions{uploadErr: fmt.Errorf("%w: exceeds your remaining storage allowance", submit.ErrQuotaExceeded)}
|
||||
w := do(appSubAPI(fs).ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "\x1f\x8bdata", nil)
|
||||
w := do(appSubAPI(fs).ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "\x1f\x8bdata", map[string]string{"Content-Digest": contentDigestOf("\x1f\x8bdata")})
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -939,11 +947,12 @@ func TestUploadSubmissionContextRateLimited(t *testing.T) {
|
||||
api.SubmitUploadCooldown = time.Minute
|
||||
eh := api.ExternalHandler()
|
||||
body := "\x1f\x8b\x08\x00 the modpack bytes"
|
||||
sent := map[string]string{"Content-Digest": contentDigestOf(body)}
|
||||
|
||||
if w := do(eh, "POST", "/api/v1/me/submissions/sub-9/context", body, nil); w.Code != http.StatusOK {
|
||||
if w := do(eh, "POST", "/api/v1/me/submissions/sub-9/context", body, sent); w.Code != http.StatusOK {
|
||||
t.Fatalf("first upload: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
w := do(eh, "POST", "/api/v1/me/submissions/sub-9/context", body, nil)
|
||||
w := do(eh, "POST", "/api/v1/me/submissions/sub-9/context", body, sent)
|
||||
if w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "submission_cooldown" {
|
||||
t.Fatalf("immediate second upload: code = %d body %s, want 429 submission_cooldown", w.Code, w.Body.String())
|
||||
}
|
||||
@@ -955,11 +964,11 @@ func TestUploadSubmissionContextRateLimited(t *testing.T) {
|
||||
api2.Now = func() time.Time { return clock }
|
||||
api2.SubmitUploadCooldown = time.Minute
|
||||
eh2 := api2.ExternalHandler()
|
||||
if w := do(eh2, "POST", "/api/v1/me/submissions/sub-9/context", body, nil); w.Code != http.StatusServiceUnavailable {
|
||||
if w := do(eh2, "POST", "/api/v1/me/submissions/sub-9/context", body, sent); w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("failed upload: code = %d, want 503", w.Code)
|
||||
}
|
||||
fs2.uploadErr = nil
|
||||
if w := do(eh2, "POST", "/api/v1/me/submissions/sub-9/context", body, nil); w.Code != http.StatusOK {
|
||||
if w := do(eh2, "POST", "/api/v1/me/submissions/sub-9/context", body, sent); w.Code != http.StatusOK {
|
||||
t.Fatalf("retry at the same instant after failure: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user