feat(files): 文件管理可新建、建目录、删除、重命名和上传,写入内容拆成多个环境变量不再超内核单变量上限
This commit is contained in:
37 files changed
+5972
-416
No files matched your search
+27
-2
@@ -300,12 +300,22 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
||||
// endpoints honestly return 503. It takes the typed clientset rather than the
|
||||
// controller-runtime client because the log subresource lives only on the typed
|
||||
// CoreV1 client, and one client covers its Job create, Pod list, and log read.
|
||||
//
|
||||
// Uploads additionally stage their bytes on this pod's disk until the Job
|
||||
// fetches them from the internal face; whatever a previous process staged is
|
||||
// orphaned (the index is in memory), so the stage starts empty.
|
||||
var files api.FileEditor
|
||||
var fileStage *fileedit.Stage
|
||||
if felisImage != "" {
|
||||
files = &fileedit.Editor{
|
||||
Runner: fileedit.NewK8sRunner(clientset),
|
||||
Config: fileEditConfig(cfg, felisImage),
|
||||
}
|
||||
fileStage = &fileedit.Stage{Dir: fileStagingDir()}
|
||||
if err := fileStage.Sweep(); err != nil {
|
||||
fmt.Fprintf(stderr, "felis api: %v — file uploads return 503\n", err)
|
||||
fileStage = nil
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(stderr, "felis api: file editor disabled (needs FELIS_IMAGE) — file endpoints return 503")
|
||||
}
|
||||
@@ -353,8 +363,13 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
||||
// restore behind each one.
|
||||
RestoreChains: jobStatus,
|
||||
Files: files,
|
||||
Submissions: submissions,
|
||||
Mailer: mailer,
|
||||
FileStage: fileStage,
|
||||
// The file Job fetches an upload from here; it runs in the minecraft
|
||||
// namespace, where the internal face is reachable like it is for the login
|
||||
// gate.
|
||||
InternalBaseURL: internalAPIBaseURL(),
|
||||
Submissions: submissions,
|
||||
Mailer: mailer,
|
||||
// The external face authenticates the local session cookie the sign-in doors
|
||||
// mint, live once `felis breakGlass` flips local_auth_enabled on. Cloudflare
|
||||
// Access, when the install sits behind it, is enforced at the edge only.
|
||||
@@ -947,6 +962,16 @@ func uploadPartsDir(contextBase string) string {
|
||||
return filepath.Join(os.TempDir(), "felis-upload-parts")
|
||||
}
|
||||
|
||||
// fileStagingDir is where file uploads wait for their Job: on the uploads
|
||||
// volume, whose capacity is its own, or the pod's /tmp when run by hand without
|
||||
// it — /tmp is the node's disk, which a burst of uploads should not fill.
|
||||
func fileStagingDir() string {
|
||||
if fi, err := os.Stat(platform.UploadsLocalPath); err == nil && fi.IsDir() {
|
||||
return filepath.Join(platform.UploadsLocalPath, ".file-staging")
|
||||
}
|
||||
return filepath.Join(os.TempDir(), "felis-file-staging")
|
||||
}
|
||||
|
||||
// contextMaxBytes resolves [registry] context_max_bytes. 0 keeps the submit
|
||||
// package's own default (1 GiB). The Cloudflare edge refuses a single request
|
||||
// body over 100 MB, which the panel's chunked upload stays under, so the edge
|
||||
|
||||
+68
-20
@@ -1,11 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/fileedit"
|
||||
)
|
||||
@@ -20,32 +24,42 @@ import (
|
||||
// config.Load: felis-api made the authorization decision (the caller owns this
|
||||
// server, and the server is stopped so the RWO world volume is free); this process
|
||||
// is the unprivileged hands that touch bytes. Its entire input is the flags
|
||||
// below plus, for a write, one environment variable. Every isolation guarantee
|
||||
// lives in the Pod spec (internal/fileedit/jobspec.go), and the path-containment
|
||||
// guarantee lives in fileedit.Execute, which resolves the path through os.Root and
|
||||
// therefore cannot be walked out of the world mount.
|
||||
// below plus, for a write, the content variables and, for an upload, one token.
|
||||
// Every isolation guarantee lives in the Pod spec (internal/fileedit/jobspec.go),
|
||||
// and the path-containment guarantee lives in fileedit.Execute, which resolves
|
||||
// every path through os.Root and therefore cannot be walked out of the world
|
||||
// mount.
|
||||
//
|
||||
// Exit status carries a specific meaning that felis-api depends on: a CALLER-fault
|
||||
// outcome — a path that escapes the root, a file that is missing or too large — is
|
||||
// a SUCCESSFUL run that prints a Result carrying an error code, so the API can map
|
||||
// it to a precise 4xx. A non-zero exit means the operation could not be attempted
|
||||
// at all (the world mount is unreadable, the result unprintable), which the API
|
||||
// reports as a 500.
|
||||
// at all (the world mount is unreadable, an upload's bytes could not be fetched
|
||||
// intact, the result unprintable), which the API reports as a 500.
|
||||
func cmdFiles(args []string, stdout, stderr io.Writer) int {
|
||||
fs := flag.NewFlagSet("files", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
op := fs.String("op", "", "operation: list, read, or write")
|
||||
op := fs.String("op", "", "operation: list, read, write, mkdir, delete, rename or upload")
|
||||
path := fs.String("path", "", "path to operate on, relative to the world root (empty = the root itself)")
|
||||
worldsRoot := fs.String("worlds-root", "/data", "mount path of the world PVC; every path resolves under it")
|
||||
expect := fs.String("expect-sha256", "", "write only: refuse unless the file's current SHA-256 (hex) is this")
|
||||
createOnly := fs.Bool("create-only", false, "write only: refuse a path that already exists")
|
||||
to := fs.String("to", "", "rename only: the destination path")
|
||||
sourceURL := fs.String("source-url", "", "upload only: felis-api URL to fetch the bytes from")
|
||||
size := fs.Int64("size", -1, "upload only: the byte count the fetched file must have")
|
||||
sum := fs.String("sha256", "", "upload only: the SHA-256 (hex) the fetched file must have")
|
||||
overwrite := fs.Bool("overwrite", false, "upload only: replace a file already at the path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
|
||||
if *op == "" {
|
||||
fmt.Fprintln(stderr, "felis files: --op is required (list, read, or write)")
|
||||
fmt.Fprintln(stderr, "felis files: --op is required")
|
||||
return 2
|
||||
}
|
||||
req := fileedit.Request{
|
||||
Op: *op, Path: *path, To: *to, Expect: *expect, CreateOnly: *createOnly, Overwrite: *overwrite,
|
||||
}
|
||||
|
||||
// New content arrives base64-encoded in the environment rather than in argv:
|
||||
// a process's arguments are world-readable on the node (/proc/<pid>/cmdline),
|
||||
@@ -53,22 +67,29 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int {
|
||||
// secrets — an RCON password in server.properties is the obvious case. The
|
||||
// encoding is what lets arbitrary bytes (CRLF endings, a BOM, a NUL) survive a
|
||||
// channel that must be a valid string.
|
||||
var content []byte
|
||||
if *op == fileedit.OpWrite {
|
||||
raw, ok := os.LookupEnv(fileedit.ContentEnv)
|
||||
if !ok {
|
||||
fmt.Fprintf(stderr, "felis files: a write needs %s in the environment\n", fileedit.ContentEnv)
|
||||
return 2
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(raw)
|
||||
switch *op {
|
||||
case fileedit.OpWrite:
|
||||
content, err := fileedit.ContentFromEnv(os.LookupEnv)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis files: %s is not valid base64: %v\n", fileedit.ContentEnv, err)
|
||||
fmt.Fprintf(stderr, "felis files: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
content = decoded
|
||||
req.Content = content
|
||||
case fileedit.OpUpload:
|
||||
token := os.Getenv(fileedit.UploadTokenEnv)
|
||||
if *sourceURL == "" || token == "" {
|
||||
fmt.Fprintf(stderr, "felis files: an upload needs --source-url and %s\n", fileedit.UploadTokenEnv)
|
||||
return 2
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
req.Upload = &fileedit.Upload{
|
||||
Size: *size, SHA256: *sum,
|
||||
Open: func() (io.ReadCloser, error) { return fetchUpload(ctx, *sourceURL, token) },
|
||||
}
|
||||
}
|
||||
|
||||
res, err := fileedit.Execute(*worldsRoot, *op, *path, content, *expect)
|
||||
res, err := fileedit.Execute(*worldsRoot, req)
|
||||
if err != nil {
|
||||
// The operation could not be attempted — infrastructure, not caller fault.
|
||||
fmt.Fprintf(stderr, "felis files: %v\n", err)
|
||||
@@ -83,3 +104,30 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// fetchUpload opens the staged upload on felis-api's internal face. There is no
|
||||
// retry: the token opens the upload once (fileedit.Stage), so a second attempt
|
||||
// could only be refused, and felis-api answers the failed Job with a 500 the
|
||||
// caller can retry whole. Redirects are refused because the request carries the
|
||||
// token and the internal face never redirects; the header timeout catches a
|
||||
// wedged endpoint, and the Job's activeDeadlineSeconds bounds the body.
|
||||
func fetchUpload(ctx context.Context, url, token string) (io.ReadCloser, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{ResponseHeaderTimeout: 30 * time.Second},
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("GET returned %s", resp.Status)
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"felis.lolicon.best/internal/fileedit"
|
||||
)
|
||||
|
||||
// filesResult is the Result a `felis files` run printed on its marked line.
|
||||
func filesResult(t *testing.T, stdout string) fileedit.Result {
|
||||
t.Helper()
|
||||
line, ok := strings.CutPrefix(strings.TrimSpace(stdout), fileedit.ResultPrefix)
|
||||
if !ok {
|
||||
t.Fatalf("stdout has no result line: %q", stdout)
|
||||
}
|
||||
var res fileedit.Result
|
||||
if err := json.Unmarshal([]byte(line), &res); err != nil {
|
||||
t.Fatalf("result line %q: %v", line, err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// stagedUpload serves body to a request carrying Bearer token, and 404 to any
|
||||
// other, the way felis-api's internal face does.
|
||||
func stagedUpload(t *testing.T, token string, body []byte) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer "+token {
|
||||
http.Error(w, "no such upload", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Write(body)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func uploadArgs(root, sourceURL string, body []byte) []string {
|
||||
sum := sha256.Sum256(body)
|
||||
return []string{
|
||||
"--op", "upload", "--path", "plugins/a.jar", "--worlds-root", root,
|
||||
"--source-url", sourceURL, "--size", "4", "--sha256", hex.EncodeToString(sum[:]),
|
||||
}
|
||||
}
|
||||
|
||||
// uploadRoot is a world with the plugins folder an upload lands in.
|
||||
func uploadRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "plugins"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestCmdFilesUpload(t *testing.T) {
|
||||
body := []byte("PK\x03\x04")
|
||||
|
||||
t.Run("fetches the staged bytes with its token and lands them", func(t *testing.T) {
|
||||
root := uploadRoot(t)
|
||||
srv := stagedUpload(t, "tok", body)
|
||||
t.Setenv(fileedit.UploadTokenEnv, "tok")
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := cmdFiles(uploadArgs(root, srv.URL+"/u", body), &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("exit %d, stderr %q", code, stderr.String())
|
||||
}
|
||||
if res := filesResult(t, stdout.String()); res.Code != "" {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(root, "plugins", "a.jar"))
|
||||
if err != nil || !bytes.Equal(got, body) {
|
||||
t.Fatalf("landed %q, %v", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
// A refused fetch is the Job failing, never a Result: the API answers it with a
|
||||
// 500 the caller retries whole.
|
||||
t.Run("a refused fetch exits 1 and lands nothing", func(t *testing.T) {
|
||||
root := uploadRoot(t)
|
||||
srv := stagedUpload(t, "tok", body)
|
||||
t.Setenv(fileedit.UploadTokenEnv, "wrong")
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := cmdFiles(uploadArgs(root, srv.URL+"/u", body), &stdout, &stderr); code != 1 {
|
||||
t.Fatalf("exit %d, want 1; stdout %q", code, stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "404") {
|
||||
t.Fatalf("stderr %q does not name the status", stderr.String())
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(root, "plugins", "a.jar")); !os.IsNotExist(err) {
|
||||
t.Fatalf("a refused fetch left a file: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// The request carries the token, and the internal face never redirects, so a
|
||||
// redirect is refused rather than followed with the token attached.
|
||||
t.Run("a redirect is not followed", func(t *testing.T) {
|
||||
root := uploadRoot(t)
|
||||
var hits atomic.Int32
|
||||
elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
w.Write(body)
|
||||
}))
|
||||
defer elsewhere.Close()
|
||||
redirecting := httptest.NewServer(http.RedirectHandler(elsewhere.URL+"/u", http.StatusFound))
|
||||
defer redirecting.Close()
|
||||
t.Setenv(fileedit.UploadTokenEnv, "tok")
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := cmdFiles(uploadArgs(root, redirecting.URL+"/u", body), &stdout, &stderr); code != 1 {
|
||||
t.Fatalf("exit %d, want 1", code)
|
||||
}
|
||||
if n := hits.Load(); n != 0 {
|
||||
t.Fatalf("the redirect target was fetched %d times", n)
|
||||
}
|
||||
})
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
token string
|
||||
drop string
|
||||
}{
|
||||
"no token": {"", ""},
|
||||
"no source URL": {"tok", "--source-url"},
|
||||
} {
|
||||
t.Run(name+" exits 2", func(t *testing.T) {
|
||||
srv := stagedUpload(t, "tok", body)
|
||||
t.Setenv(fileedit.UploadTokenEnv, tc.token)
|
||||
args := uploadArgs(uploadRoot(t), srv.URL+"/u", body)
|
||||
if tc.drop != "" {
|
||||
for i, a := range args {
|
||||
if a == tc.drop {
|
||||
args = append(args[:i:i], args[i+2:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := cmdFiles(args, &stdout, &stderr); code != 2 {
|
||||
t.Fatalf("exit %d, want 2", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdFilesWrite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
args := []string{"--op", "write", "--path", "ops.json", "--worlds-root", root}
|
||||
|
||||
t.Run("reassembles the content parts", func(t *testing.T) {
|
||||
content := []byte("[]\r\n")
|
||||
t.Setenv(fileedit.ContentPartsEnv, "1")
|
||||
t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString(content))
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := cmdFiles(args, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("exit %d, stderr %q", code, stderr.String())
|
||||
}
|
||||
if res := filesResult(t, stdout.String()); res.Code != "" {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(root, "ops.json")); err != nil || !bytes.Equal(got, content) {
|
||||
t.Fatalf("wrote %q, %v", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
// Writing what did arrive of an incomplete spec would truncate the file.
|
||||
t.Run("an incomplete content spec exits 2 and writes nothing", func(t *testing.T) {
|
||||
t.Setenv(fileedit.ContentPartsEnv, "2")
|
||||
t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString([]byte("x")))
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := cmdFiles([]string{"--op", "write", "--path", "new.txt", "--worlds-root", root}, &stdout, &stderr); code != 2 {
|
||||
t.Fatalf("exit %d, want 2", code)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(root, "new.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("an incomplete spec wrote a file: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A caller-fault outcome is a successful run carrying a code, so felis-api can
|
||||
// answer the precise 4xx instead of a 500.
|
||||
func TestCmdFilesCallerFaultIsAResult(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := cmdFiles([]string{"--op", "mkdir", "--path", "../out", "--worlds-root", root}, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("exit %d, stderr %q", code, stderr.String())
|
||||
}
|
||||
if res := filesResult(t, stdout.String()); res.Code != fileedit.CodeBadPath {
|
||||
t.Fatalf("result = %+v, want code %s", res, fileedit.CodeBadPath)
|
||||
}
|
||||
stdout.Reset()
|
||||
if code := cmdFiles([]string{"--worlds-root", root}, &stdout, &stderr); code != 2 {
|
||||
t.Fatalf("no --op: exit %d, want 2", code)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user