feat(files): add the server file editor
Give an owner a way to repair the one failure no other endpoint covers: a
server that will not boot because a single line of server.properties or a
plugin's YAML is wrong. Until now that needed a human with cluster access.
felis-api cannot touch a world in-process — the world PVC is ReadWriteOnce
and its lifecycle belongs to the operator's StatefulSet — so the work runs
as a one-shot Job, and the server must be stopped first because a running
one holds the volume. That is the same constraint that shapes restore and
backup, and the handlers enforce the stopped gate the same way.
What is different is that the caller wants the OUTPUT, not just the side
effect. The Job prints its result to stdout and felis-api reads it back
through the pods/log subresource, which needs no permission felis-api does
not already hold: jobs:create, pods:list, pods/log:get. No pods/exec, no
pods/portforward, not even pods:get. The price is latency — every operation
is a Pod schedule — which is why this is a repair tool and not a file
manager.
Containment is structural, not textual. Every filesystem access goes through
os.Root, the stdlib's escape-proof directory handle, which resolves each
component against the open root descriptor and refuses "..", absolute paths,
and symlinks leading outside. The string-prefix check used elsewhere is not
reused here: it validates a path as text and then opens it as a path, and a
world directory holds attacker-influenced content, so a symlink swapped in
between those two steps is a live threat rather than a theoretical one.
os.Root has no such window because the check and the open are one operation.
The Job's isolation is a strict subset of a restore Pod's: the weak
felis-restore SA with its token auto-mount disabled, exactly one volume (the
world PVC, mounted read-only for list and read so two of the three
operations cannot mutate anything), no Secret, no ConfigMap, no database
URL, non-root with an fsGroup matching the operator's so a written file is
readable by the server that later mounts it, and backoffLimit 0 so a failed
write is never silently retried as a second write.
Two limits on the surface are worth stating plainly, because the mount is
the server's whole working directory rather than a config subtree:
* A write accepts arbitrary bytes at any path, so an owner can place a
loadable plugin jar. This is deliberate — it is what a hosting panel's
file manager does, scoped to a server the caller already owns and
already drives through /command — but it is the one owner-tier route
that lands executable code in a backend pod, since images are
admin-only and modpack submissions need an admin verdict.
* config/paper-global.yml is refused on read. felis-lobby's entrypoint
writes FELIS_FORWARDING_SECRET into it on every boot, and that value is
identical on every backend, so reading it from a server you own would
hand you the handshake key for everyone else's. It is the only path in
the mount that is not the caller's own data, and therefore the only
denial. The comparison is on the cleaned path, or ./config/... would
walk straight through it.
Writing that file is still allowed: it leaks nothing, and the entrypoint
rewrites it whole on every boot regardless.
The write body's content field is a *[]byte rather than a []byte for the
reason permissionRequest.Value is a *bool — a plain slice makes absent,
null, and empty indistinguishable, so a body of {} would decode to nil and
truncate the target to zero bytes while answering 200, destroying the very
config the caller opened the editor to repair.
This commit is contained in:
14 files changed
+2994
No files matched your search
@@ -0,0 +1,311 @@
|
||||
// Package fileedit implements the server file editor (list / read / write a file
|
||||
// in a server's world volume), the lever an owner reaches for when a server will
|
||||
// not boot because one line of server.properties or a plugin's YAML is wrong —
|
||||
// the one repair that otherwise requires a human with cluster access.
|
||||
//
|
||||
// felis-api cannot touch a world in-process: the world PVC is ReadWriteOnce and
|
||||
// its lifecycle is owned by the operator's StatefulSet, so the API has nothing to
|
||||
// mount at request time. That is the same constraint that makes internal/restore
|
||||
// and internal/backupjob one-shot Jobs, and it has the same two consequences here:
|
||||
// the work runs as a Job, and the server MUST be stopped first (a running server
|
||||
// holds the RWO volume, so the Job could not mount it). The handlers enforce the
|
||||
// stopped gate exactly as the backup/restore handlers do.
|
||||
//
|
||||
// # How a result gets back
|
||||
//
|
||||
// A file operation is unusual among Felis's Jobs in that the CALLER wants the
|
||||
// output, not just the side effect: a listing and a file's bytes must reach the
|
||||
// browser that asked. The transport is deliberately the narrowest one available —
|
||||
// the Job PRINTS its result to stdout and felis-api reads it back through the
|
||||
// pods/log subresource, which it already has RBAC for. This is the whole reason
|
||||
// the design needs no new permission:
|
||||
//
|
||||
// create the Job → jobs:create (already held)
|
||||
// find its Pod → pods:list (already held, for the §8 console)
|
||||
// read the result → pods/log:get (already held, for the §8 console)
|
||||
//
|
||||
// No pods/exec, no pods/portforward, not even pods:get — the least-privilege line
|
||||
// internal/platform/rbac.go draws and a test asserts. The write direction travels
|
||||
// the other way, on the Job spec felis-api creates (see ContentEnv).
|
||||
//
|
||||
// The price is latency: every operation is a Pod schedule + image pull, so a
|
||||
// listing takes seconds rather than milliseconds. That is inherent to RWO plus a
|
||||
// stopped server, not a property of this transport, and it is why the editor is a
|
||||
// repair tool rather than a file manager.
|
||||
//
|
||||
// The Editor depends on the Runner interface, so the orchestration and the error
|
||||
// mapping are unit-tested against an in-memory fake; the client-go implementation
|
||||
// (k8sjobs.go) compiles here but is exercised only against a live cluster.
|
||||
package fileedit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/naming"
|
||||
)
|
||||
|
||||
// Errors the Editor returns, which internal/api maps onto HTTP status codes
|
||||
// (handlers_files.go). They are sentinels rather than an error type because the
|
||||
// mapping needs nothing but identity — the human-readable detail rides along in
|
||||
// the wrapped message.
|
||||
var (
|
||||
// ErrNotFound is a path that resolves inside the world root but has nothing at
|
||||
// it. It is distinct from a missing SERVER, which the handler resolves earlier.
|
||||
ErrNotFound = errors.New("fileedit: no such file or directory")
|
||||
// ErrBadPath is a path the Job refused: it escapes the world root (via "..", an
|
||||
// absolute path, or a symlink), or names a directory where a file is required.
|
||||
ErrBadPath = errors.New("fileedit: path is not accessible")
|
||||
// ErrTooLarge is a read of a file over MaxReadBytes or a write over
|
||||
// MaxWriteBytes.
|
||||
ErrTooLarge = errors.New("fileedit: file is too large for the editor")
|
||||
)
|
||||
|
||||
// Runner is the cluster-side half of one file operation: render and create the
|
||||
// Job, wait for its Pod to reach a terminal phase, and return the marked JSON
|
||||
// payload the Pod printed. It is one method rather than a create/poll/read trio
|
||||
// because felis-api cannot poll a Job at all (no jobs:get — see FilesJobName), so
|
||||
// there is no intermediate state a caller could usefully observe; the operation is
|
||||
// synchronous from the API's point of view whether or not the seam pretends
|
||||
// otherwise.
|
||||
//
|
||||
// It is an interface so the Editor's orchestration and error mapping are tested
|
||||
// against a fake; the client-go implementation (K8sRunner) is integration-only.
|
||||
type Runner interface {
|
||||
// Run creates the Job for p and returns the raw JSON payload from the
|
||||
// ResultPrefix line of its Pod's log.
|
||||
Run(ctx context.Context, p JobParams) ([]byte, error)
|
||||
}
|
||||
|
||||
// Config parameterises the file editor. Image has no default on purpose: it is
|
||||
// deployment-specific, and when it is empty cmd/felis leaves the API's FileEditor
|
||||
// nil so the endpoints report 503 rather than creating a Job that cannot run.
|
||||
type Config struct {
|
||||
// Namespace is where the world PVCs live and the Job runs (the minecraft
|
||||
// namespace), co-located with the world it edits.
|
||||
Namespace string
|
||||
// ServiceAccount is the weak SA the Pod runs as. It reuses felis-restore (bare,
|
||||
// no Role/RoleBinding anywhere): a file-editor Pod needs no K8s API access, only
|
||||
// filesystem access to the one PVC it mounts, so a second identity with the same
|
||||
// empty powers would be a manifest to maintain for no isolation gain.
|
||||
ServiceAccount string
|
||||
// Image is the felis binary image; the Job runs `felis files` from it.
|
||||
Image string
|
||||
// WorldsRoot is the in-Pod mount path of the world PVC, and therefore the root
|
||||
// every caller-supplied path is resolved against. It defaults to the operator's
|
||||
// own dataMountPath ("/data") rather than restore's "/world" so the paths a user
|
||||
// types are the paths the MINECRAFT SERVER sees: "server.properties" means the
|
||||
// same file in the editor as it does in every wiki page and support thread.
|
||||
WorldsRoot string
|
||||
// Deadline caps the Pod's wall-clock (activeDeadlineSeconds).
|
||||
Deadline time.Duration
|
||||
// Timeout caps how long felis-api waits for a result before giving up. It bounds
|
||||
// an HTTP handler's block, so it is the tighter of the two: a Pod that is still
|
||||
// pulling its image when this expires leaves the caller with a clean 504 while
|
||||
// the Job runs on harmlessly to its own Deadline and is then TTL'd away.
|
||||
Timeout time.Duration
|
||||
// CPULimit / MemLimit cap the container.
|
||||
CPULimit string
|
||||
MemLimit string
|
||||
// RunAsUser / RunAsGroup / FSGroup are the Pod's runtime identity. FSGroup MUST
|
||||
// match the operator StatefulSet's runtime group, or a file this Pod writes
|
||||
// would be unreadable by the minecraft server that later mounts the same PVC.
|
||||
RunAsUser int64
|
||||
RunAsGroup int64
|
||||
FSGroup int64
|
||||
// TTLAfterFinished is how long a finished Job lingers before the Job controller
|
||||
// collects it. felis-api holds no jobs:delete, so this is the ONLY cleanup path;
|
||||
// it must stay comfortably longer than the moment felis-api needs to read the
|
||||
// Pod's log, because the TTL takes the Pod (and its log) with the Job.
|
||||
TTLAfterFinished time.Duration
|
||||
}
|
||||
|
||||
// defaults applied when a Config field is left zero. They are sized for what a
|
||||
// file operation actually is — open one file, print a few KiB — which is orders of
|
||||
// magnitude smaller than a restore's tar of an entire world.
|
||||
const (
|
||||
defaultNamespace = "minecraft"
|
||||
defaultServiceAccount = "felis-restore"
|
||||
defaultWorldsRoot = "/data"
|
||||
defaultDeadline = 2 * time.Minute
|
||||
defaultTimeout = 90 * time.Second
|
||||
defaultCPULimit = "500m"
|
||||
defaultMemLimit = "256Mi"
|
||||
defaultRunAsID = int64(1000)
|
||||
defaultTTL = 2 * time.Minute
|
||||
)
|
||||
|
||||
// withDefaults returns a copy of c with zero fields filled, so a partially
|
||||
// configured Config (or the zero value, in tests) is always usable.
|
||||
func (c Config) withDefaults() Config {
|
||||
if c.Namespace == "" {
|
||||
c.Namespace = defaultNamespace
|
||||
}
|
||||
if c.ServiceAccount == "" {
|
||||
c.ServiceAccount = defaultServiceAccount
|
||||
}
|
||||
if c.WorldsRoot == "" {
|
||||
c.WorldsRoot = defaultWorldsRoot
|
||||
}
|
||||
if c.Deadline <= 0 {
|
||||
c.Deadline = defaultDeadline
|
||||
}
|
||||
if c.Timeout <= 0 {
|
||||
c.Timeout = defaultTimeout
|
||||
}
|
||||
if c.CPULimit == "" {
|
||||
c.CPULimit = defaultCPULimit
|
||||
}
|
||||
if c.MemLimit == "" {
|
||||
c.MemLimit = defaultMemLimit
|
||||
}
|
||||
if c.RunAsUser == 0 {
|
||||
c.RunAsUser = defaultRunAsID
|
||||
}
|
||||
if c.RunAsGroup == 0 {
|
||||
c.RunAsGroup = defaultRunAsID
|
||||
}
|
||||
if c.FSGroup == 0 {
|
||||
c.FSGroup = defaultRunAsID
|
||||
}
|
||||
if c.TTLAfterFinished <= 0 {
|
||||
c.TTLAfterFinished = defaultTTL
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Editor is the production internal/api.FileEditor. It holds no mutable state.
|
||||
type Editor struct {
|
||||
Runner Runner
|
||||
Config Config
|
||||
}
|
||||
|
||||
// List returns one directory's entries, resolved under the server's world root.
|
||||
// An empty path lists the world root itself.
|
||||
func (e *Editor) List(ctx context.Context, server, path string) ([]Entry, bool, error) {
|
||||
res, err := e.run(ctx, server, OpList, path, nil)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
// A genuinely empty directory unmarshals Entries as nil; normalise it so the
|
||||
// handler serialises [] rather than null.
|
||||
if res.Entries == nil {
|
||||
res.Entries = []Entry{}
|
||||
}
|
||||
return res.Entries, res.Truncated, nil
|
||||
}
|
||||
|
||||
// Read returns a file's bytes, resolved under the server's world root.
|
||||
func (e *Editor) Read(ctx context.Context, server, path string) ([]byte, error) {
|
||||
res, err := e.run(ctx, server, OpRead, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A zero-length file unmarshals Content as nil, which is a legitimate result,
|
||||
// not an error — normalise so the caller never has to distinguish nil from empty.
|
||||
if res.Content == nil {
|
||||
res.Content = []byte{}
|
||||
}
|
||||
return res.Content, nil
|
||||
}
|
||||
|
||||
// Write replaces a file's contents, creating it if absent (but never creating
|
||||
// parent directories — see the write helper in exec.go).
|
||||
func (e *Editor) Write(ctx context.Context, server, path string, content []byte) error {
|
||||
_, err := e.run(ctx, server, OpWrite, path, content)
|
||||
return err
|
||||
}
|
||||
|
||||
// run is the shared body of all three operations: mint an op id, render the
|
||||
// params, run the Job, and translate the Result's code into a sentinel error.
|
||||
//
|
||||
// The size check happens HERE, before a Job is created, as well as inside the Pod.
|
||||
// That is not redundancy for its own sake: an oversized write would otherwise be
|
||||
// rejected by the API SERVER (etcd's object limit) as an opaque failure, long after
|
||||
// felis-api had committed to the request, instead of as a clean 413.
|
||||
func (e *Editor) run(ctx context.Context, server, op, path string, content []byte) (Result, error) {
|
||||
if op == OpWrite && len(content) > MaxWriteBytes {
|
||||
return Result{}, fmt.Errorf("%w: content is %d bytes, the limit is %d",
|
||||
ErrTooLarge, len(content), MaxWriteBytes)
|
||||
}
|
||||
|
||||
cfg := e.Config.withDefaults()
|
||||
opID, err := newOpID()
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
// Bound the wait here rather than trusting the caller's context: this is an HTTP
|
||||
// handler's goroutine and the Pod it waits on may never become ready (an
|
||||
// unschedulable node, an unpullable image). The Job's own activeDeadlineSeconds
|
||||
// cleans up the cluster side independently.
|
||||
ctx, cancel := context.WithTimeout(ctx, cfg.Timeout)
|
||||
defer cancel()
|
||||
|
||||
payload, err := e.Runner.Run(ctx, JobParams{
|
||||
Server: server,
|
||||
OpID: opID,
|
||||
Op: op,
|
||||
Path: path,
|
||||
Content: content,
|
||||
WorldPVC: naming.WorldPVCName(server),
|
||||
Namespace: cfg.Namespace,
|
||||
ServiceAccount: cfg.ServiceAccount,
|
||||
Image: cfg.Image,
|
||||
WorldsRoot: cfg.WorldsRoot,
|
||||
Deadline: cfg.Deadline,
|
||||
CPULimit: cfg.CPULimit,
|
||||
MemLimit: cfg.MemLimit,
|
||||
RunAsUser: cfg.RunAsUser,
|
||||
RunAsGroup: cfg.RunAsGroup,
|
||||
FSGroup: cfg.FSGroup,
|
||||
TTLAfterFinished: cfg.TTLAfterFinished,
|
||||
})
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
var res Result
|
||||
if err := json.Unmarshal(payload, &res); err != nil {
|
||||
return Result{}, fmt.Errorf("fileedit: malformed result from the file Job: %w", err)
|
||||
}
|
||||
return res, resultError(res)
|
||||
}
|
||||
|
||||
// resultError translates a Result's code into the sentinel the API maps. An
|
||||
// unrecognised code is deliberately NOT swallowed as success: a Job reporting a
|
||||
// failure this build does not know about must still fail the request, or a future
|
||||
// code would silently read as "it worked".
|
||||
func resultError(res Result) error {
|
||||
switch res.Code {
|
||||
case "":
|
||||
return nil
|
||||
case CodeNotFound:
|
||||
return fmt.Errorf("%w: %s", ErrNotFound, res.Error)
|
||||
case CodeBadPath:
|
||||
return fmt.Errorf("%w: %s", ErrBadPath, res.Error)
|
||||
case CodeTooLarge:
|
||||
return fmt.Errorf("%w: %s", ErrTooLarge, res.Error)
|
||||
default:
|
||||
return fmt.Errorf("fileedit: file operation failed (%s): %s", res.Code, res.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// newOpID mints the per-invocation tag that names the Job and labels its Pod. 64
|
||||
// bits of randomness is far more than collision-avoidance needs (a collision only
|
||||
// matters between two operations alive in the same TTL window), but the id is also
|
||||
// what selects THIS operation's Pod when reading the result back — so a collision
|
||||
// would mean reading another operation's output, and the margin is cheap. Hex
|
||||
// keeps it a valid DNS-1123 name fragment and a valid label value.
|
||||
func newOpID() (string, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("fileedit: generate op id: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package fileedit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeRunner stands in for the cluster: it records the JobParams the Editor
|
||||
// rendered and replays a canned payload as if a Pod had printed it.
|
||||
type fakeRunner struct {
|
||||
calls int
|
||||
got []JobParams
|
||||
payload []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeRunner) Run(_ context.Context, p JobParams) ([]byte, error) {
|
||||
f.calls++
|
||||
f.got = append(f.got, p)
|
||||
return f.payload, f.err
|
||||
}
|
||||
|
||||
func mustPayload(t *testing.T, res Result) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// TestEditorRendersParams checks the Editor projects each operation onto the
|
||||
// JobParams the renderer expects — in particular that the world PVC comes from the
|
||||
// shared naming convention rather than being assembled locally, which is what keeps
|
||||
// the editor pointed at the same volume the operator created and the reaper deletes.
|
||||
func TestEditorRendersParams(t *testing.T) {
|
||||
t.Run("list", func(t *testing.T) {
|
||||
r := &fakeRunner{payload: mustPayload(t, Result{Entries: []Entry{{Name: "a"}}})}
|
||||
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
||||
|
||||
entries, truncated, err := e.List(context.Background(), "survival", "config")
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || truncated {
|
||||
t.Fatalf("entries=%+v truncated=%v", entries, truncated)
|
||||
}
|
||||
p := r.got[0]
|
||||
if p.Op != OpList || p.Path != "config" || p.Server != "survival" {
|
||||
t.Fatalf("params = %+v", p)
|
||||
}
|
||||
if p.WorldPVC != "world-survival-0" {
|
||||
t.Fatalf("WorldPVC = %q, want the naming convention's world-survival-0", p.WorldPVC)
|
||||
}
|
||||
if p.WorldsRoot != "/data" {
|
||||
t.Fatalf("WorldsRoot = %q, want the default /data", p.WorldsRoot)
|
||||
}
|
||||
if len(p.Content) != 0 {
|
||||
t.Fatal("a list must carry no content")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read", func(t *testing.T) {
|
||||
r := &fakeRunner{payload: mustPayload(t, Result{Content: []byte("motd=hi\n")})}
|
||||
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
||||
|
||||
got, err := e.Read(context.Background(), "survival", "server.properties")
|
||||
if err != nil {
|
||||
t.Fatalf("Read: %v", err)
|
||||
}
|
||||
if string(got) != "motd=hi\n" {
|
||||
t.Fatalf("content = %q", got)
|
||||
}
|
||||
if r.got[0].Op != OpRead || r.got[0].Path != "server.properties" {
|
||||
t.Fatalf("params = %+v", r.got[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("write", func(t *testing.T) {
|
||||
r := &fakeRunner{payload: mustPayload(t, Result{})}
|
||||
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
||||
|
||||
if err := e.Write(context.Background(), "survival", "ops.json", []byte("[]")); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
if r.got[0].Op != OpWrite || string(r.got[0].Content) != "[]" {
|
||||
t.Fatalf("params = %+v", r.got[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestEditorMintsAFreshOpID guards the RBAC-forced invariant from the other side:
|
||||
// the Job name is unique per invocation only because the Editor mints a new id
|
||||
// every time. If it ever cached one, two operations would collide on a name
|
||||
// felis-api has no permission to delete.
|
||||
func TestEditorMintsAFreshOpID(t *testing.T) {
|
||||
r := &fakeRunner{payload: mustPayload(t, Result{})}
|
||||
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
||||
|
||||
for range 3 {
|
||||
if _, err := e.Read(context.Background(), "survival", "x"); err != nil {
|
||||
t.Fatalf("Read: %v", err)
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, p := range r.got {
|
||||
if p.OpID == "" {
|
||||
t.Fatal("op id must never be empty")
|
||||
}
|
||||
if seen[p.OpID] {
|
||||
t.Fatalf("op id %q reused across invocations", p.OpID)
|
||||
}
|
||||
seen[p.OpID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditorMapsResultCodes proves a caller-fault Result becomes the sentinel the
|
||||
// API maps. The default branch matters most: an unrecognised code must FAIL rather
|
||||
// than read as success, so a future Job version reporting a new failure mode cannot
|
||||
// be silently mistaken for a completed operation.
|
||||
func TestEditorMapsResultCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
code string
|
||||
want error
|
||||
}{
|
||||
{"missing file", CodeNotFound, ErrNotFound},
|
||||
{"escaping path", CodeBadPath, ErrBadPath},
|
||||
{"oversized", CodeTooLarge, ErrTooLarge},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := &fakeRunner{payload: mustPayload(t, Result{Code: tc.code, Error: "detail here"})}
|
||||
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
||||
_, err := e.Read(context.Background(), "survival", "x")
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Fatalf("err = %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("an unknown code still fails", func(t *testing.T) {
|
||||
r := &fakeRunner{payload: mustPayload(t, Result{Code: "from_the_future", Error: "?"})}
|
||||
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
||||
if _, err := e.Read(context.Background(), "survival", "x"); err == nil {
|
||||
t.Fatal("an unrecognised failure code must not read as success")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a malformed payload is an error, not an empty success", func(t *testing.T) {
|
||||
r := &fakeRunner{payload: []byte("not json at all")}
|
||||
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
||||
if _, err := e.Read(context.Background(), "survival", "x"); err == nil {
|
||||
t.Fatal("a malformed result must fail")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a runner failure propagates", func(t *testing.T) {
|
||||
r := &fakeRunner{err: errors.New("pod never scheduled")}
|
||||
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
||||
if _, err := e.Read(context.Background(), "survival", "x"); err == nil {
|
||||
t.Fatal("a runner error must propagate")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestEditorRefusesOversizedWriteBeforeTheCluster checks the size ceiling is applied
|
||||
// before a Job is rendered. Letting it through would surface as an opaque etcd
|
||||
// object-size rejection long after felis-api committed to the request.
|
||||
func TestEditorRefusesOversizedWriteBeforeTheCluster(t *testing.T) {
|
||||
r := &fakeRunner{payload: mustPayload(t, Result{})}
|
||||
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
||||
|
||||
err := e.Write(context.Background(), "survival", "big.txt", make([]byte, MaxWriteBytes+1))
|
||||
if !errors.Is(err, ErrTooLarge) {
|
||||
t.Fatalf("err = %v, want ErrTooLarge", err)
|
||||
}
|
||||
if r.calls != 0 {
|
||||
t.Fatal("an oversized write must never reach the cluster")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditorNormalisesEmptyResults pins that "nothing there" is a success, not a
|
||||
// nil surprise: an empty directory lists as [] and a zero-length file reads as
|
||||
// empty bytes, so no caller has to distinguish nil from empty.
|
||||
func TestEditorNormalisesEmptyResults(t *testing.T) {
|
||||
r := &fakeRunner{payload: mustPayload(t, Result{})}
|
||||
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
||||
|
||||
entries, _, err := e.List(context.Background(), "survival", "empty")
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if entries == nil {
|
||||
t.Fatal("an empty directory must list as [], not nil")
|
||||
}
|
||||
|
||||
content, err := e.Read(context.Background(), "survival", "empty.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Read: %v", err)
|
||||
}
|
||||
if content == nil {
|
||||
t.Fatal("a zero-length file must read as empty bytes, not nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractResult covers the log-scanning half of the transport: pods/log merges
|
||||
// stdout and stderr, so the payload must be found by its marker among arbitrary
|
||||
// noise rather than by assuming the log is pure JSON.
|
||||
func TestExtractResult(t *testing.T) {
|
||||
t.Run("finds the payload among stderr noise", func(t *testing.T) {
|
||||
log := "warning: something from the runtime\n" +
|
||||
ResultPrefix + `{"content":"aGk="}` + "\n" +
|
||||
"a trailing stderr line\n"
|
||||
payload, ok := extractResult(log)
|
||||
if !ok {
|
||||
t.Fatal("payload not found")
|
||||
}
|
||||
var res Result
|
||||
if err := json.Unmarshal(payload, &res); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if string(res.Content) != "hi" {
|
||||
t.Fatalf("content = %q", res.Content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("takes the last marked line", func(t *testing.T) {
|
||||
log := ResultPrefix + `{"code":"bad_path"}` + "\n" + ResultPrefix + `{"content":"aGk="}` + "\n"
|
||||
payload, ok := extractResult(log)
|
||||
if !ok {
|
||||
t.Fatal("payload not found")
|
||||
}
|
||||
if string(payload) != `{"content":"aGk="}` {
|
||||
t.Fatalf("payload = %s, want the last marked line", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reports absence rather than guessing", func(t *testing.T) {
|
||||
if _, ok := extractResult("no marker here\njust noise\n"); ok {
|
||||
t.Fatal("a log with no marked line must report not-found")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package fileedit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The three operations the editor supports. The set is deliberately closed and
|
||||
// tiny: list a directory, read a file, write a file. There is no rename, delete,
|
||||
// or chmod — each would need its own containment and audit story, and none is
|
||||
// required to fix a broken server.properties, which is what this subsystem exists
|
||||
// for.
|
||||
//
|
||||
// A write DOES accept arbitrary bytes at any path inside the mount, and that is a
|
||||
// real capability rather than an oversight: the root is the server's whole working
|
||||
// directory (see Config.WorldsRoot), so an owner can write plugins/<x>.jar and
|
||||
// Paper will load it on the next boot. It is the same power a hosting panel's file
|
||||
// manager gives, scoped to a server the caller already owns and already controls
|
||||
// through /command. Note what it is NOT scoped by: admin image curation. Images
|
||||
// are admin-only (POST /images, POST /images/build) and modpack submissions need
|
||||
// an admin verdict, so this is the one owner-tier route that lands executable code
|
||||
// in a backend pod. That trade was made deliberately; if it is ever revisited, the
|
||||
// guard belongs in write() below, which is the single choke point all three
|
||||
// callers route through.
|
||||
const (
|
||||
OpList = "list"
|
||||
OpRead = "read"
|
||||
OpWrite = "write"
|
||||
)
|
||||
|
||||
// Result codes. A failure that is the CALLER's fault travels back as a Result
|
||||
// with a Code rather than as a non-zero exit, so felis-api can map it onto a
|
||||
// precise 4xx (handlers_files.go) instead of collapsing every failure into "the
|
||||
// Job died" 500. Only an infrastructure failure — the world mount unreadable, the
|
||||
// result unprintable — exits non-zero.
|
||||
const (
|
||||
CodeBadPath = "bad_path" // escapes the world root, is absolute, or is otherwise unopenable
|
||||
CodeNotFound = "not_found" // resolves inside the root but nothing is there
|
||||
CodeTooLarge = "too_large" // the file exceeds MaxReadBytes
|
||||
)
|
||||
|
||||
// ResultPrefix marks the single stdout line carrying the JSON Result. The Job's
|
||||
// output is read back through the pods/log subresource, which returns the
|
||||
// container's stdout and stderr MERGED — so a Go runtime warning, a libc message,
|
||||
// or anything else the container writes to stderr lands in the same stream. The
|
||||
// marker is what makes the payload findable in that mixed stream: felis-api scans
|
||||
// for the last line carrying this prefix rather than assuming the log is pure
|
||||
// JSON. Without it any stray stderr byte would corrupt every response.
|
||||
const ResultPrefix = "FELIS-FILES-RESULT: "
|
||||
|
||||
// ContentEnv is the environment variable the write path carries new file content
|
||||
// in (base64). It travels on the Job spec felis-api creates, because felis-api
|
||||
// holds `jobs: create` but NOT `secrets: create` in the minecraft namespace
|
||||
// (internal/platform.APIMinecraftRole) — a Secret is not available to it, so the
|
||||
// Job spec is the only channel into the Pod. The consequence is that written
|
||||
// content is readable by anyone holding jobs:get in the minecraft namespace,
|
||||
// which is a cluster-admin-level power; it is NOT readable by felis-operator,
|
||||
// felis-reaper, or any weak Job SA, none of which hold that verb.
|
||||
const ContentEnv = "FELIS_FILE_CONTENT"
|
||||
|
||||
// Size and count ceilings. Every one of them exists because the result travels
|
||||
// through a Kubernetes object or a pod log, neither of which is an unbounded pipe:
|
||||
//
|
||||
// - MaxWriteBytes bounds the env var on the Job spec. etcd refuses an object
|
||||
// over ~1.5MiB, and the base64 of the content is ~4/3 of it, so 256KiB leaves
|
||||
// an order of magnitude of headroom for the rest of the spec. Any real
|
||||
// server.properties / ops.json / bukkit.yml is a few KiB.
|
||||
// - MaxReadBytes bounds what a read pulls back through the pod log INTO
|
||||
// felis-api's memory. Without it a caller could name a 500MiB region file and
|
||||
// make the API buffer it — a trivial memory DoS from an ordinary owner-tier
|
||||
// request. 1MiB comfortably covers every config file and refuses world data.
|
||||
// - MaxEntries bounds a listing. A world's region/ directory legitimately holds
|
||||
// thousands of .mca files, so this truncates rather than errors (Truncated
|
||||
// says so), keeping the log line bounded while still being useful.
|
||||
const (
|
||||
MaxWriteBytes = 256 << 10 // 256 KiB
|
||||
MaxReadBytes = 1 << 20 // 1 MiB
|
||||
MaxEntries = 2000
|
||||
)
|
||||
|
||||
// Entry is one directory entry in a listing. It carries only what a file browser
|
||||
// needs to render a row and decide whether the entry is descendable; mode bits,
|
||||
// ownership, and inode data are deliberately absent — they are not actionable
|
||||
// through this editor (there is no chmod/chown op) and would only widen what a
|
||||
// listing discloses about the node.
|
||||
type Entry struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
ModTime time.Time `json:"mod_time"`
|
||||
}
|
||||
|
||||
// Result is the single JSON object the Job prints and felis-api parses back. One
|
||||
// shape covers all three ops so the transport has exactly one thing to find and
|
||||
// unmarshal; the op decides which fields are populated.
|
||||
//
|
||||
// Content is []byte, so encoding/json base64-encodes it on the way out and
|
||||
// decodes it on the way back with no hand-rolled codec. That is what makes the
|
||||
// read path binary-safe: a config file with a stray non-UTF-8 byte round-trips
|
||||
// intact instead of being mangled into U+FFFD by a string round-trip.
|
||||
type Result struct {
|
||||
// Code and Error are set together on a caller-fault failure; both empty means
|
||||
// the op succeeded.
|
||||
Code string `json:"code,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
Entries []Entry `json:"entries,omitempty"`
|
||||
Content []byte `json:"content,omitempty"`
|
||||
// Truncated reports that the listing hit MaxEntries and is incomplete, so a
|
||||
// client renders "showing first N" rather than silently implying the directory
|
||||
// is smaller than it is.
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// Execute performs op on the file named by path, resolved inside root, and returns
|
||||
// the Result to print. root is the in-Pod mount path of the server's world PVC;
|
||||
// path is the caller-supplied relative path underneath it.
|
||||
//
|
||||
// CONTAINMENT INVARIANT: every filesystem access goes through *os.Root, never
|
||||
// through a path string this function assembled. os.Root is the stdlib's
|
||||
// escape-proof directory handle — it resolves each component against the open root
|
||||
// descriptor and refuses any traversal that would leave it, whether by "..", by an
|
||||
// absolute path, or by a SYMLINK pointing outside. That last case is why the
|
||||
// string-prefix check in internal/backup/tarlocal.go is not reused here: a prefix
|
||||
// test validates the path as text, then opens it as a path, and between those two
|
||||
// steps a symlink can be swapped in (TOCTOU). A world directory holds
|
||||
// attacker-influenced content — players create files through ordinary gameplay,
|
||||
// and plugins create more — so a symlink escaping to /etc or to another server's
|
||||
// mount is a live threat, not a theoretical one. os.Root closes it structurally:
|
||||
// there is no window between the check and the open because they are the same
|
||||
// operation.
|
||||
//
|
||||
// The path is passed to os.Root verbatim apart from mapping "" to ".". In
|
||||
// particular an ABSOLUTE path is NOT rewritten into a relative one — it is handed
|
||||
// to os.Root as-is and refused. Silently reinterpreting "/etc/passwd" as
|
||||
// "<root>/etc/passwd" would turn an unambiguous escape attempt into a successful
|
||||
// read of a file the caller did not name, which is exactly the confusion this
|
||||
// editor must not have.
|
||||
func Execute(root, op, path string, content []byte) (Result, error) {
|
||||
r, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
// The world mount itself is unopenable: infrastructure, not caller fault.
|
||||
return Result{}, fmt.Errorf("open world root %q: %w", root, err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
|
||||
switch op {
|
||||
case OpList:
|
||||
return list(r, path), nil
|
||||
case OpRead:
|
||||
return read(r, path), nil
|
||||
case OpWrite:
|
||||
return write(r, path, content), nil
|
||||
default:
|
||||
return Result{}, fmt.Errorf("unknown op %q", op)
|
||||
}
|
||||
}
|
||||
|
||||
// list reads one directory. It does not recurse: a browser asks for one level at
|
||||
// a time, and recursion would make both the result size and the traversal cost
|
||||
// unbounded in a world directory.
|
||||
func list(r *os.Root, path string) Result {
|
||||
f, err := r.Open(path)
|
||||
if err != nil {
|
||||
return failure(err, path)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// ReadDir(MaxEntries+1) reads one MORE than the ceiling so the overflow is
|
||||
// detectable without walking the whole directory: if the extra entry came back,
|
||||
// the listing is truncated. io.EOF means the directory ended within the limit.
|
||||
dirents, err := f.ReadDir(MaxEntries + 1)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return failure(err, path)
|
||||
}
|
||||
|
||||
truncated := len(dirents) > MaxEntries
|
||||
if truncated {
|
||||
dirents = dirents[:MaxEntries]
|
||||
}
|
||||
|
||||
entries := make([]Entry, 0, len(dirents))
|
||||
for _, de := range dirents {
|
||||
e := Entry{Name: de.Name(), IsDir: de.IsDir()}
|
||||
// Info() can fail on an entry deleted between the ReadDir and the stat (a
|
||||
// running plugin rotating a log, say). That is not a reason to fail the whole
|
||||
// listing, so the entry is reported with a zero size/mtime rather than dropped
|
||||
// — a name that exists is still useful to the caller.
|
||||
if info, err := de.Info(); err == nil {
|
||||
e.Size, e.ModTime = info.Size(), info.ModTime()
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return Result{Entries: entries, Truncated: truncated}
|
||||
}
|
||||
|
||||
// secretConfigPath is the one file in a world mount holding PLATFORM secret
|
||||
// material rather than the owner's own configuration. felis-lobby's entrypoint
|
||||
// writes FELIS_FORWARDING_SECRET into it on every boot, and that value is
|
||||
// identical on every backend in the cluster — operator.buildEnv injects one Secret
|
||||
// everywhere. Reading it out of a server you own would therefore hand you the
|
||||
// Velocity modern-forwarding handshake key for EVERYONE's servers: cross-tenant
|
||||
// material that merely happens to sit in your volume. Every other path here is the
|
||||
// caller's own data, which is why this is the only denial.
|
||||
//
|
||||
// Refusing it costs no legitimate repair. The entrypoint rewrites the file whole
|
||||
// on every boot and its own header says "Do not hand-edit", so an edit made
|
||||
// through this editor could never survive a restart anyway. Only the READ is
|
||||
// denied; a write is left alone because writing the file leaks nothing and is
|
||||
// equally futile.
|
||||
//
|
||||
// ponytail: an exact match on one cleaned path, not a pattern. This is the whole
|
||||
// known exposure — grep FELIS_FORWARDING_SECRET across deploy/ — and if another
|
||||
// image ever persists a platform secret into the mount, add its path here rather
|
||||
// than inventing a matcher.
|
||||
const secretConfigPath = "config/paper-global.yml"
|
||||
|
||||
// read returns a file's bytes. It stats first so an oversized file is refused
|
||||
// BEFORE any of it is buffered — checking after the read would mean the memory
|
||||
// blow-up this ceiling exists to prevent has already happened.
|
||||
func read(r *os.Root, name string) Result {
|
||||
// path.Clean, not a raw compare: "./config/paper-global.yml",
|
||||
// "config//paper-global.yml" and "config/../config/paper-global.yml" all name
|
||||
// the same file, and a string equality test would wave every one of them
|
||||
// through. Cleaning collapses them to the single canonical form this matches.
|
||||
// Slash-based path (not filepath) is correct because the Job container is always
|
||||
// Linux, whatever the developer machine rendering the spec runs.
|
||||
if path.Clean(name) == secretConfigPath {
|
||||
return Result{Code: CodeBadPath, Error: fmt.Sprintf(
|
||||
"%s holds the proxy forwarding secret, which is shared cluster-wide, and is not readable through the editor", name)}
|
||||
}
|
||||
|
||||
f, err := r.Open(name)
|
||||
if err != nil {
|
||||
return failure(err, name)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return failure(err, name)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return Result{Code: CodeBadPath, Error: fmt.Sprintf("%s is a directory, not a file", name)}
|
||||
}
|
||||
if info.Size() > MaxReadBytes {
|
||||
return Result{Code: CodeTooLarge, Error: fmt.Sprintf(
|
||||
"%s is %d bytes; the editor reads at most %d", name, info.Size(), MaxReadBytes)}
|
||||
}
|
||||
|
||||
// LimitReader is belt-and-braces against the file growing between the Stat and
|
||||
// the read: the ceiling then holds on the bytes actually buffered, not merely on
|
||||
// the size observed a moment earlier.
|
||||
b, err := io.ReadAll(io.LimitReader(f, MaxReadBytes))
|
||||
if err != nil {
|
||||
return failure(err, name)
|
||||
}
|
||||
return Result{Content: b}
|
||||
}
|
||||
|
||||
// write replaces a file's contents. It truncates rather than appends, and it does
|
||||
// NOT create parent directories: every path this editor writes is an existing
|
||||
// config file being corrected, so an unexpected mkdir would more likely be a typo
|
||||
// materialising a stray directory in the world mount than an intent.
|
||||
//
|
||||
// O_CREATE is still allowed so a config file the server has not yet generated can
|
||||
// be authored. os.Root applies the same containment to the create as to an open,
|
||||
// so a symlink at the target pointing outside the root is refused rather than
|
||||
// followed — the classic "write through a planted symlink" escape.
|
||||
func write(r *os.Root, path string, content []byte) Result {
|
||||
if len(content) > MaxWriteBytes {
|
||||
// Defence in depth: felis-api already refuses an oversized write with a 413
|
||||
// before rendering the Job. Re-checking here keeps the ceiling true even if
|
||||
// this entrypoint is ever driven directly.
|
||||
return Result{Code: CodeTooLarge, Error: fmt.Sprintf(
|
||||
"content is %d bytes; the editor writes at most %d", len(content), MaxWriteBytes)}
|
||||
}
|
||||
f, err := r.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return failure(err, path)
|
||||
}
|
||||
if _, err := f.Write(content); err != nil {
|
||||
f.Close()
|
||||
return failure(err, path)
|
||||
}
|
||||
// Close is where a buffered-write error surfaces, so its error is honoured
|
||||
// rather than deferred-and-dropped: reporting success on a write that did not
|
||||
// land would leave the caller believing a broken config was fixed.
|
||||
if err := f.Close(); err != nil {
|
||||
return failure(err, path)
|
||||
}
|
||||
return Result{}
|
||||
}
|
||||
|
||||
// failure maps a filesystem error onto a caller-facing Result code. Anything that
|
||||
// is genuinely "nothing is there" becomes not_found; EVERYTHING else — including
|
||||
// every os.Root containment refusal — becomes bad_path.
|
||||
//
|
||||
// That default is deliberate. os.Root reports an escape as a *fs.PathError with no
|
||||
// exported sentinel to match on, so the mapping cannot test for "escaped"
|
||||
// positively; it tests for the one benign case it can name and refuses the rest.
|
||||
// Failing closed this way means a future os.Root error kind is reported as a bad
|
||||
// path rather than leaking through as a success.
|
||||
//
|
||||
// The error text is included because it is generated by the stdlib from the
|
||||
// caller's OWN path inside their OWN world mount, so it discloses nothing they
|
||||
// could not learn by listing — and it is the difference between a usable "no such
|
||||
// file" and an opaque 400.
|
||||
func failure(err error, path string) Result {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return Result{Code: CodeNotFound, Error: fmt.Sprintf("%s does not exist", path)}
|
||||
}
|
||||
return Result{Code: CodeBadPath, Error: err.Error()}
|
||||
}
|
||||
|
||||
// Print writes r as the single marked stdout line the Job's reader looks for. The
|
||||
// JSON is written with no indentation on purpose: the payload must occupy exactly
|
||||
// ONE log line, because the reader identifies it by a line prefix. An indented
|
||||
// encoding would split it across lines and make it unfindable.
|
||||
func Print(w io.Writer, res Result) error {
|
||||
b, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal result: %w", err)
|
||||
}
|
||||
_, err = fmt.Fprintf(w, "%s%s\n", ResultPrefix, b)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package fileedit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// worldRoot builds a throwaway world directory with a couple of files and returns
|
||||
// its path, plus the path of a sibling directory OUTSIDE it holding a secret. The
|
||||
// sibling stands in for /etc — anything the editor must never reach — so an escape
|
||||
// that succeeds is observable as the secret's contents coming back, not merely as
|
||||
// a missing error.
|
||||
func worldRoot(t *testing.T) (root, outside string) {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
root = filepath.Join(base, "world")
|
||||
outside = filepath.Join(base, "outside")
|
||||
for _, d := range []string{root, outside, filepath.Join(root, "config")} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", d, err)
|
||||
}
|
||||
}
|
||||
write := func(p, content string) {
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
write(filepath.Join(root, "server.properties"), "motd=hello\n")
|
||||
write(filepath.Join(root, "config", "paper.yml"), "verbose: false\n")
|
||||
write(filepath.Join(outside, "secret.txt"), "TOP-SECRET")
|
||||
return root, outside
|
||||
}
|
||||
|
||||
// TestExecuteContainment is the security test of this package. The world directory
|
||||
// holds attacker-influenced content (players and plugins create files in it), so
|
||||
// each vector below is a path a caller could genuinely supply to try to leave the
|
||||
// world mount. Every one MUST be refused — a refusal is CodeBadPath (or, where the
|
||||
// kernel resolves it to nothing at all, CodeNotFound), never a successful read.
|
||||
//
|
||||
// The assertion is deliberately doubled: the Result must carry a failure Code AND
|
||||
// the secret's contents must not appear in it. Checking only the code would pass a
|
||||
// hypothetical future regression that returned a code alongside populated content.
|
||||
func TestExecuteContainment(t *testing.T) {
|
||||
root, outside := worldRoot(t)
|
||||
|
||||
// A symlink INSIDE the world pointing OUTSIDE it — the vector a string-prefix
|
||||
// check cannot stop and the reason this package uses os.Root. The link is a
|
||||
// perfectly ordinary file to a prefix test ("world/escape-link" is under
|
||||
// "world/"), yet opening it lands on the secret.
|
||||
if err := os.Symlink(outside, filepath.Join(root, "escape-link")); err != nil {
|
||||
t.Skipf("symlinks unavailable on this platform: %v", err)
|
||||
}
|
||||
// A symlink pointing at an absolute path outside the root, planted at the exact
|
||||
// name a caller would then "read" — the write-through-a-planted-symlink shape.
|
||||
if err := os.Symlink(filepath.Join(outside, "secret.txt"), filepath.Join(root, "planted.txt")); err != nil {
|
||||
t.Fatalf("symlink: %v", err)
|
||||
}
|
||||
|
||||
vectors := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"parent traversal", "../outside/secret.txt"},
|
||||
{"nested parent traversal", "config/../../outside/secret.txt"},
|
||||
{"absolute path", filepath.Join(outside, "secret.txt")},
|
||||
{"absolute path to etc", "/etc/passwd"},
|
||||
{"symlinked directory", "escape-link/secret.txt"},
|
||||
{"symlinked file", "planted.txt"},
|
||||
{"traversal past the filesystem root", "../../../../../../etc/passwd"},
|
||||
}
|
||||
|
||||
for _, v := range vectors {
|
||||
t.Run("read "+v.name, func(t *testing.T) {
|
||||
res, err := Execute(root, OpRead, v.path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute returned an infrastructure error, want a contained refusal: %v", err)
|
||||
}
|
||||
if res.Code == "" {
|
||||
t.Fatalf("path %q was ALLOWED (content=%q) — containment breached", v.path, res.Content)
|
||||
}
|
||||
if bytes.Contains(res.Content, []byte("TOP-SECRET")) {
|
||||
t.Fatalf("path %q leaked out-of-root content despite code %q", v.path, res.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The write side must be contained by the same invariant: a planted symlink
|
||||
// must not become a write into the file it points at.
|
||||
t.Run("write through a planted symlink is refused", func(t *testing.T) {
|
||||
res, err := Execute(root, OpWrite, "planted.txt", []byte("pwned"))
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if res.Code == "" {
|
||||
t.Fatal("write through a symlink leaving the root was ALLOWED")
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(outside, "secret.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read secret: %v", err)
|
||||
}
|
||||
if string(b) != "TOP-SECRET" {
|
||||
t.Fatalf("out-of-root file was MODIFIED through the symlink: %q", b)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("write escaping by traversal is refused", func(t *testing.T) {
|
||||
res, err := Execute(root, OpWrite, "../outside/new.txt", []byte("pwned"))
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if res.Code == "" {
|
||||
t.Fatal("write via ../ was ALLOWED")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "new.txt")); err == nil {
|
||||
t.Fatal("a file was created outside the world root")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list escaping by traversal is refused", func(t *testing.T) {
|
||||
res, err := Execute(root, OpList, "../outside", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if res.Code == "" {
|
||||
t.Fatalf("listing outside the root was ALLOWED: %+v", res.Entries)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestExecuteHappyPath proves the three ops actually work inside the root, so the
|
||||
// containment test above cannot be trivially satisfied by a function that refuses
|
||||
// everything.
|
||||
func TestExecuteHappyPath(t *testing.T) {
|
||||
root, _ := worldRoot(t)
|
||||
|
||||
t.Run("list the world root", func(t *testing.T) {
|
||||
res, err := Execute(root, OpList, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if res.Code != "" {
|
||||
t.Fatalf("unexpected failure %s: %s", res.Code, res.Error)
|
||||
}
|
||||
got := map[string]Entry{}
|
||||
for _, e := range res.Entries {
|
||||
got[e.Name] = e
|
||||
}
|
||||
if _, ok := got["server.properties"]; !ok {
|
||||
t.Fatalf("server.properties missing from listing: %+v", res.Entries)
|
||||
}
|
||||
if e, ok := got["config"]; !ok || !e.IsDir {
|
||||
t.Fatalf("config should be listed as a directory: %+v", got["config"])
|
||||
}
|
||||
if e := got["server.properties"]; e.Size != int64(len("motd=hello\n")) {
|
||||
t.Fatalf("size = %d, want %d", e.Size, len("motd=hello\n"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list a subdirectory", func(t *testing.T) {
|
||||
res, err := Execute(root, OpList, "config", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if res.Code != "" || len(res.Entries) != 1 || res.Entries[0].Name != "paper.yml" {
|
||||
t.Fatalf("unexpected listing: %+v (code %q)", res.Entries, res.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read a file", func(t *testing.T) {
|
||||
res, err := Execute(root, OpRead, "server.properties", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if res.Code != "" || string(res.Content) != "motd=hello\n" {
|
||||
t.Fatalf("content = %q, code = %q", res.Content, res.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("write replaces content, then reads back", func(t *testing.T) {
|
||||
if res, err := Execute(root, OpWrite, "server.properties", []byte("motd=changed\n")); err != nil || res.Code != "" {
|
||||
t.Fatalf("write failed: %v / %+v", err, res)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(root, "server.properties"))
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if string(b) != "motd=changed\n" {
|
||||
t.Fatalf("on-disk content = %q, want the written bytes", b)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("write creates a new file but not parent directories", func(t *testing.T) {
|
||||
if res, err := Execute(root, OpWrite, "ops.json", []byte("[]")); err != nil || res.Code != "" {
|
||||
t.Fatalf("creating a new file should succeed: %v / %+v", err, res)
|
||||
}
|
||||
res, err := Execute(root, OpWrite, "nope/deep.txt", []byte("x"))
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if res.Code == "" {
|
||||
t.Fatal("writing into a non-existent directory should fail, not mkdir it")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing file reads as not_found", func(t *testing.T) {
|
||||
res, err := Execute(root, OpRead, "absent.txt", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if res.Code != CodeNotFound {
|
||||
t.Fatalf("code = %q, want %q", res.Code, CodeNotFound)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reading a directory is bad_path, not a garbled read", func(t *testing.T) {
|
||||
res, err := Execute(root, OpRead, "config", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if res.Code != CodeBadPath {
|
||||
t.Fatalf("code = %q, want %q", res.Code, CodeBadPath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("oversized write is refused", func(t *testing.T) {
|
||||
res, err := Execute(root, OpWrite, "big.txt", make([]byte, MaxWriteBytes+1))
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if res.Code != CodeTooLarge {
|
||||
t.Fatalf("code = %q, want %q", res.Code, CodeTooLarge)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("oversized read is refused before buffering", func(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(root, "huge.bin"), make([]byte, MaxReadBytes+1), 0o644); err != nil {
|
||||
t.Fatalf("write huge: %v", err)
|
||||
}
|
||||
res, err := Execute(root, OpRead, "huge.bin", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if res.Code != CodeTooLarge {
|
||||
t.Fatalf("code = %q, want %q", res.Code, CodeTooLarge)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestReadIsBinarySafe proves the []byte/base64 round-trip preserves bytes that a
|
||||
// string round-trip would destroy. A config file with a stray non-UTF-8 byte must
|
||||
// come back byte-identical, or "read, edit one line, write" would silently corrupt
|
||||
// the rest of the file.
|
||||
func TestReadIsBinarySafe(t *testing.T) {
|
||||
root, _ := worldRoot(t)
|
||||
raw := []byte{0xff, 0xfe, 'o', 'k', 0x00, 0x80}
|
||||
if err := os.WriteFile(filepath.Join(root, "raw.bin"), raw, 0o644); err != nil {
|
||||
t.Fatalf("write raw: %v", err)
|
||||
}
|
||||
|
||||
res, err := Execute(root, OpRead, "raw.bin", nil)
|
||||
if err != nil || res.Code != "" {
|
||||
t.Fatalf("read failed: %v / %+v", err, res)
|
||||
}
|
||||
|
||||
// Round-trip through the wire encoding, which is how felis-api actually receives it.
|
||||
var buf bytes.Buffer
|
||||
if err := Print(&buf, res); err != nil {
|
||||
t.Fatalf("Print: %v", err)
|
||||
}
|
||||
line := strings.TrimPrefix(strings.TrimSpace(buf.String()), ResultPrefix)
|
||||
var back Result
|
||||
if err := json.Unmarshal([]byte(line), &back); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if !bytes.Equal(back.Content, raw) {
|
||||
t.Fatalf("content = % x, want % x", back.Content, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintIsOneMarkedLine pins the transport contract: felis-api finds the payload
|
||||
// by scanning merged stdout+stderr for ResultPrefix, so the payload must be exactly
|
||||
// one line and must carry the marker. An indented encoder would break the reader.
|
||||
func TestPrintIsOneMarkedLine(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := Print(&buf, Result{Entries: []Entry{{Name: "a"}, {Name: "b"}}}); err != nil {
|
||||
t.Fatalf("Print: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.HasPrefix(out, ResultPrefix) {
|
||||
t.Fatalf("output lacks the marker: %q", out)
|
||||
}
|
||||
if n := strings.Count(strings.TrimSuffix(out, "\n"), "\n"); n != 0 {
|
||||
t.Fatalf("payload spans %d extra lines; it must be exactly one", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadRefusesTheForwardingSecret pins the one path denial in this package. The
|
||||
// value in config/paper-global.yml is the SAME on every backend in the cluster, so
|
||||
// a read here is not a caller reading their own data — it is the Velocity handshake
|
||||
// key for everyone else's servers.
|
||||
//
|
||||
// The equivalent-spelling cases are the substance of this test. A bare string
|
||||
// compare against the constant would pass the first case and wave through all the
|
||||
// rest, which is exactly the bug this guards; each alternative below names the same
|
||||
// file to the kernel, so each must be refused identically.
|
||||
func TestReadRefusesTheForwardingSecret(t *testing.T) {
|
||||
root, _ := worldRoot(t)
|
||||
const secret = "secret: aVeryRealForwardingKey"
|
||||
if err := os.WriteFile(filepath.Join(root, "config", "paper-global.yml"),
|
||||
[]byte(secret), 0o644); err != nil {
|
||||
t.Fatalf("seed paper-global.yml: %v", err)
|
||||
}
|
||||
|
||||
for _, spelling := range []string{
|
||||
"config/paper-global.yml",
|
||||
"./config/paper-global.yml",
|
||||
"config//paper-global.yml",
|
||||
"config/../config/paper-global.yml",
|
||||
"config/./paper-global.yml",
|
||||
} {
|
||||
res, err := Execute(root, OpRead, spelling, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: Execute: %v", spelling, err)
|
||||
}
|
||||
if res.Code != CodeBadPath {
|
||||
t.Errorf("%s: code = %q, want %q — an equivalent spelling must not bypass the denial",
|
||||
spelling, res.Code, CodeBadPath)
|
||||
}
|
||||
if strings.Contains(string(res.Content), "aVeryRealForwardingKey") {
|
||||
t.Errorf("%s: the forwarding secret leaked into the result", spelling)
|
||||
}
|
||||
}
|
||||
|
||||
// The denial is READ-only and exact: a neighbouring file in the same directory
|
||||
// stays readable, or the guard would have broken ordinary config repair.
|
||||
res, err := Execute(root, OpRead, "config/paper.yml", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if res.Code != "" {
|
||||
t.Errorf("config/paper.yml: code = %q, want success — the denial must not widen", res.Code)
|
||||
}
|
||||
|
||||
// Writing it is still allowed: it leaks nothing, and the lobby entrypoint
|
||||
// rewrites the file whole on every boot regardless.
|
||||
res, err = Execute(root, OpWrite, "config/paper-global.yml", []byte("proxies: {}\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if res.Code != "" {
|
||||
t.Errorf("write code = %q, want success — only the read is denied", res.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package fileedit
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// Label keys applied to file-editor objects, mirroring internal/restore and
|
||||
// internal/backupjob so all three world-touching executors are observable the same
|
||||
// way. LabelOpID is the one addition: it is how felis-api finds THIS invocation's
|
||||
// Pod among any others, which matters here in a way it does not for restore —
|
||||
// file operations are interactive and repeated, so several may be in flight or
|
||||
// lingering inside their TTL at once.
|
||||
const (
|
||||
LabelManagedBy = "app.kubernetes.io/managed-by"
|
||||
LabelComponent = "app.kubernetes.io/component"
|
||||
LabelServer = "felis.lolicon.best/server"
|
||||
LabelOpID = "felis.lolicon.best/files-op"
|
||||
|
||||
managedByValue = "felis-files"
|
||||
componentValue = "world-files"
|
||||
|
||||
worldVolume = "world"
|
||||
felisBinaryPath = "/usr/local/bin/felis"
|
||||
containerName = "files"
|
||||
)
|
||||
|
||||
// JobParams are the rendered inputs to a file-editor Job, derived from a server +
|
||||
// operation + Config by the Editor. jobspec is a pure function of them so the
|
||||
// security-critical Job shape is unit-tested without a cluster.
|
||||
type JobParams struct {
|
||||
Server string
|
||||
// OpID is the per-invocation identifier that both names the Job and labels its
|
||||
// Pod. See Editor.run for why every invocation gets a fresh one.
|
||||
OpID string
|
||||
Op string
|
||||
Path string
|
||||
Content []byte // OpWrite only
|
||||
WorldPVC string
|
||||
|
||||
Namespace string
|
||||
ServiceAccount string
|
||||
Image string
|
||||
WorldsRoot string
|
||||
Deadline time.Duration
|
||||
CPULimit string
|
||||
MemLimit string
|
||||
RunAsUser int64
|
||||
RunAsGroup int64
|
||||
FSGroup int64
|
||||
|
||||
TTLAfterFinished time.Duration
|
||||
}
|
||||
|
||||
// FilesJobName is the Job name for one file operation. Unlike RestoreJobName it is
|
||||
// NOT a pure function of the server: it carries the per-invocation OpID.
|
||||
//
|
||||
// That difference is forced by RBAC and is the single most load-bearing decision in
|
||||
// this package. felis-api holds `jobs: create` in the minecraft namespace and
|
||||
// NOTHING else — no jobs:get, no jobs:delete (internal/platform.APIMinecraftRole).
|
||||
// So it can neither poll a Job nor clean one up; ttlSecondsAfterFinished is the only
|
||||
// reclamation. With a deterministic name the FIRST file operation would leave a
|
||||
// completed Job squatting the name for the whole TTL window, and every subsequent
|
||||
// operation would collide with it — and, unable to delete it, the editor would be
|
||||
// wedged until the TTL expired. A restore can accept that (it runs once per
|
||||
// incident); an editor cannot (browse a directory, open a file, save it — three
|
||||
// operations in as many seconds). internal/backupjob reached the same conclusion for
|
||||
// the same reason.
|
||||
func FilesJobName(server, opID string) string { return "files-" + server + "-" + opID }
|
||||
|
||||
func filesLabels(p JobParams) map[string]string {
|
||||
return map[string]string{
|
||||
LabelManagedBy: managedByValue,
|
||||
LabelComponent: componentValue,
|
||||
LabelServer: p.Server,
|
||||
LabelOpID: p.OpID,
|
||||
}
|
||||
}
|
||||
|
||||
// FilesJob renders the file-editor Job. Its isolation is the strictest of the three
|
||||
// world executors — a strict SUBSET of what a restore Pod gets — and every guarantee
|
||||
// is asserted by jobspec_test.go, because no cluster runs in this environment:
|
||||
//
|
||||
// - runs under the weak felis-restore SA (never the felis-api SA) with its token
|
||||
// auto-mount disabled, so it cannot reach the K8s API (spec §16, §21). It reuses
|
||||
// that bare, Role-less SA for the same reason internal/backupjob does: this Pod
|
||||
// needs no K8s API access at all, so a second identity with the same (empty)
|
||||
// powers would be a manifest to maintain for no isolation gain;
|
||||
// - mounts EXACTLY ONE volume — the world PVC — and NO Secret, NO ConfigMap, and
|
||||
// NO backup PVC. It is therefore strictly blinder than the backup Pod, which
|
||||
// mounts the config Secret to self-record its row: a file-editor Pod has nothing
|
||||
// to record, so it is handed no database URL and no credential of any kind (the
|
||||
// four-power red line, spec §22);
|
||||
// - mounts that one volume READ-ONLY for list and read. Only a write needs to
|
||||
// mutate the world, so two of the three operations physically cannot — the
|
||||
// kernel refuses, not merely the code. This is why readOnly is derived from the
|
||||
// op rather than fixed;
|
||||
// - runs as a non-root, fixed uid/gid with an fsGroup matching the operator's
|
||||
// StatefulSet, so a file this Pod writes is owned by the identity the minecraft
|
||||
// server later runs as — a config file the server cannot read would be worse
|
||||
// than no edit at all;
|
||||
// - no privilege, no privilege escalation, read-only root filesystem, drop ALL
|
||||
// capabilities. The world mount is the only writable path, and only on a write;
|
||||
// - activeDeadlineSeconds + backoffLimit=0 so a wedged mount cannot loop or hang
|
||||
// forever; ttlSecondsAfterFinished GCs the finished Job, which — see
|
||||
// FilesJobName — is the ONLY cleanup available to felis-api.
|
||||
//
|
||||
// The container runs `/usr/local/bin/felis files` (cmd/felis), which performs the
|
||||
// operation under os.Root containment and prints the marked JSON Result line that
|
||||
// felis-api reads back through pods/log.
|
||||
func FilesJob(p JobParams) (*batchv1.Job, error) {
|
||||
if p.Image == "" {
|
||||
return nil, fmt.Errorf("fileedit: image is empty")
|
||||
}
|
||||
if p.WorldPVC == "" {
|
||||
return nil, fmt.Errorf("fileedit: world PVC name is required")
|
||||
}
|
||||
if p.OpID == "" {
|
||||
return nil, fmt.Errorf("fileedit: op id is required")
|
||||
}
|
||||
if p.Op != OpList && p.Op != OpRead && p.Op != OpWrite {
|
||||
return nil, fmt.Errorf("fileedit: unknown op %q", p.Op)
|
||||
}
|
||||
if len(p.Content) > MaxWriteBytes {
|
||||
return nil, fmt.Errorf("fileedit: content is %d bytes, over the %d limit", len(p.Content), MaxWriteBytes)
|
||||
}
|
||||
limits, err := resourceLimits(p.CPULimit, p.MemLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deadline := int64(p.Deadline / time.Second)
|
||||
if deadline <= 0 {
|
||||
deadline = int64(defaultDeadline / time.Second)
|
||||
}
|
||||
ttl := int32(p.TTLAfterFinished / time.Second)
|
||||
if ttl <= 0 {
|
||||
ttl = int32(defaultTTL / time.Second)
|
||||
}
|
||||
|
||||
// Only a write may mutate the world. Mounting read-only for the other two ops
|
||||
// makes "a listing cannot damage a world" a kernel guarantee rather than a
|
||||
// code-review one.
|
||||
readOnlyWorld := p.Op != OpWrite
|
||||
|
||||
container := corev1.Container{
|
||||
Name: containerName,
|
||||
Image: p.Image,
|
||||
Command: []string{felisBinaryPath, "files"},
|
||||
Args: []string{
|
||||
"--op", p.Op,
|
||||
"--path", p.Path,
|
||||
"--worlds-root", p.WorldsRoot,
|
||||
},
|
||||
VolumeMounts: []corev1.VolumeMount{
|
||||
{Name: worldVolume, MountPath: p.WorldsRoot, ReadOnly: readOnlyWorld},
|
||||
},
|
||||
Resources: corev1.ResourceRequirements{Limits: limits, Requests: limits},
|
||||
SecurityContext: &corev1.SecurityContext{
|
||||
Privileged: boolPtr(false),
|
||||
AllowPrivilegeEscalation: boolPtr(false),
|
||||
ReadOnlyRootFilesystem: boolPtr(true),
|
||||
Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}},
|
||||
},
|
||||
}
|
||||
|
||||
// New content rides the Job spec as a base64 env var. felis-api cannot create a
|
||||
// Secret (it holds secrets:get only), so the spec is the sole channel into the
|
||||
// Pod; base64 keeps arbitrary bytes — CRLF line endings, a UTF-8 BOM, a binary
|
||||
// blob — intact through a field that must be a valid string. The env var is set
|
||||
// ONLY for a write, so a list/read Job spec carries no caller content at all.
|
||||
if p.Op == OpWrite {
|
||||
container.Env = []corev1.EnvVar{{
|
||||
Name: ContentEnv,
|
||||
Value: base64.StdEncoding.EncodeToString(p.Content),
|
||||
}}
|
||||
}
|
||||
|
||||
job := &batchv1.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: FilesJobName(p.Server, p.OpID),
|
||||
Namespace: p.Namespace,
|
||||
Labels: filesLabels(p),
|
||||
},
|
||||
Spec: batchv1.JobSpec{
|
||||
// One shot: a file operation that failed must surface its failure, not be
|
||||
// retried behind the caller's back — a retried write is a second write.
|
||||
BackoffLimit: int32Ptr(0),
|
||||
ActiveDeadlineSeconds: int64Ptr(deadline),
|
||||
TTLSecondsAfterFinished: int32Ptr(ttl),
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{Labels: filesLabels(p)},
|
||||
Spec: corev1.PodSpec{
|
||||
RestartPolicy: corev1.RestartPolicyNever,
|
||||
ServiceAccountName: p.ServiceAccount,
|
||||
AutomountServiceAccountToken: boolPtr(false),
|
||||
SecurityContext: &corev1.PodSecurityContext{
|
||||
RunAsNonRoot: boolPtr(true),
|
||||
RunAsUser: int64Ptr(p.RunAsUser),
|
||||
RunAsGroup: int64Ptr(p.RunAsGroup),
|
||||
FSGroup: int64Ptr(p.FSGroup),
|
||||
},
|
||||
Containers: []corev1.Container{container},
|
||||
Volumes: []corev1.Volume{{
|
||||
Name: worldVolume,
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
|
||||
ClaimName: p.WorldPVC,
|
||||
ReadOnly: readOnlyWorld,
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// resourceLimits parses the CPU/memory limits into a ResourceList.
|
||||
func resourceLimits(cpu, mem string) (corev1.ResourceList, error) {
|
||||
if cpu == "" {
|
||||
cpu = defaultCPULimit
|
||||
}
|
||||
if mem == "" {
|
||||
mem = defaultMemLimit
|
||||
}
|
||||
cpuQty, err := resource.ParseQuantity(cpu)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fileedit: invalid cpu limit %q: %w", cpu, err)
|
||||
}
|
||||
memQty, err := resource.ParseQuantity(mem)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fileedit: invalid memory limit %q: %w", mem, err)
|
||||
}
|
||||
return corev1.ResourceList{
|
||||
corev1.ResourceCPU: cpuQty,
|
||||
corev1.ResourceMemory: memQty,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
func int32Ptr(i int32) *int32 { return &i }
|
||||
func int64Ptr(i int64) *int64 { return &i }
|
||||
@@ -0,0 +1,259 @@
|
||||
package fileedit
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
func testParams(op string) JobParams {
|
||||
return JobParams{
|
||||
Server: "survival",
|
||||
OpID: "deadbeefcafe0001",
|
||||
Op: op,
|
||||
Path: "server.properties",
|
||||
WorldPVC: "world-survival-0",
|
||||
Namespace: "minecraft",
|
||||
ServiceAccount: "felis-restore",
|
||||
Image: "registry.example/felis:v1",
|
||||
WorldsRoot: "/data",
|
||||
Deadline: 2 * time.Minute,
|
||||
CPULimit: "500m",
|
||||
MemLimit: "256Mi",
|
||||
RunAsUser: 1000,
|
||||
RunAsGroup: 1000,
|
||||
FSGroup: 1000,
|
||||
TTLAfterFinished: 2 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesJobIsolation asserts every isolation guarantee FilesJob documents. No
|
||||
// cluster runs in this environment, so this pure-function test IS the enforcement:
|
||||
// if someone loosens the Pod spec, this is what catches it.
|
||||
func TestFilesJobIsolation(t *testing.T) {
|
||||
job, err := FilesJob(testParams(OpRead))
|
||||
if err != nil {
|
||||
t.Fatalf("FilesJob: %v", err)
|
||||
}
|
||||
spec := job.Spec.Template.Spec
|
||||
|
||||
t.Run("runs under the weak SA with its token un-mounted", func(t *testing.T) {
|
||||
if spec.ServiceAccountName != "felis-restore" {
|
||||
t.Fatalf("SA = %q, want the weak felis-restore", spec.ServiceAccountName)
|
||||
}
|
||||
if spec.AutomountServiceAccountToken == nil || *spec.AutomountServiceAccountToken {
|
||||
t.Fatal("the SA token MUST NOT be auto-mounted — the Pod must not reach the K8s API")
|
||||
}
|
||||
})
|
||||
|
||||
// The four-power red line: a file-editor Pod holds no credential of any kind. It
|
||||
// is strictly blinder than the backup Pod, which does mount the config Secret.
|
||||
t.Run("mounts exactly one volume and no credential", func(t *testing.T) {
|
||||
if len(spec.Volumes) != 1 {
|
||||
t.Fatalf("volumes = %d, want exactly 1 (the world PVC)", len(spec.Volumes))
|
||||
}
|
||||
v := spec.Volumes[0]
|
||||
if v.PersistentVolumeClaim == nil || v.PersistentVolumeClaim.ClaimName != "world-survival-0" {
|
||||
t.Fatalf("the sole volume must be the world PVC, got %+v", v)
|
||||
}
|
||||
if v.Secret != nil || v.ConfigMap != nil || v.Projected != nil {
|
||||
t.Fatalf("no Secret/ConfigMap/Projected volume may be mounted, got %+v", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("runs non-root with the operator's runtime identity", func(t *testing.T) {
|
||||
sc := spec.SecurityContext
|
||||
if sc == nil || sc.RunAsNonRoot == nil || !*sc.RunAsNonRoot {
|
||||
t.Fatal("RunAsNonRoot must be true")
|
||||
}
|
||||
// FSGroup must match the minecraft server's group or a file this Pod writes
|
||||
// would be unreadable by the server that later mounts the same volume.
|
||||
if sc.RunAsUser == nil || *sc.RunAsUser != 1000 ||
|
||||
sc.RunAsGroup == nil || *sc.RunAsGroup != 1000 ||
|
||||
sc.FSGroup == nil || *sc.FSGroup != 1000 {
|
||||
t.Fatalf("uid/gid/fsGroup must all be 1000, got %+v", sc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("container drops every privilege", func(t *testing.T) {
|
||||
if len(spec.Containers) != 1 {
|
||||
t.Fatalf("containers = %d, want 1", len(spec.Containers))
|
||||
}
|
||||
sc := spec.Containers[0].SecurityContext
|
||||
if sc == nil {
|
||||
t.Fatal("the container needs a SecurityContext")
|
||||
}
|
||||
if sc.Privileged == nil || *sc.Privileged {
|
||||
t.Fatal("Privileged must be false")
|
||||
}
|
||||
if sc.AllowPrivilegeEscalation == nil || *sc.AllowPrivilegeEscalation {
|
||||
t.Fatal("AllowPrivilegeEscalation must be false")
|
||||
}
|
||||
if sc.ReadOnlyRootFilesystem == nil || !*sc.ReadOnlyRootFilesystem {
|
||||
t.Fatal("ReadOnlyRootFilesystem must be true")
|
||||
}
|
||||
if sc.Capabilities == nil || len(sc.Capabilities.Drop) != 1 || sc.Capabilities.Drop[0] != "ALL" {
|
||||
t.Fatalf("capabilities must drop ALL, got %+v", sc.Capabilities)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("is one-shot, deadlined, and self-collecting", func(t *testing.T) {
|
||||
if job.Spec.BackoffLimit == nil || *job.Spec.BackoffLimit != 0 {
|
||||
t.Fatal("BackoffLimit must be 0 — a retried write is a second write")
|
||||
}
|
||||
if job.Spec.ActiveDeadlineSeconds == nil || *job.Spec.ActiveDeadlineSeconds != 120 {
|
||||
t.Fatalf("ActiveDeadlineSeconds = %v, want 120", job.Spec.ActiveDeadlineSeconds)
|
||||
}
|
||||
// The TTL is the ONLY cleanup available: felis-api holds no jobs:delete.
|
||||
if job.Spec.TTLSecondsAfterFinished == nil || *job.Spec.TTLSecondsAfterFinished != 120 {
|
||||
t.Fatalf("TTLSecondsAfterFinished = %v, want 120", job.Spec.TTLSecondsAfterFinished)
|
||||
}
|
||||
if spec.RestartPolicy != corev1.RestartPolicyNever {
|
||||
t.Fatalf("RestartPolicy = %q, want Never", spec.RestartPolicy)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("runs the files entrypoint with the op as arguments", func(t *testing.T) {
|
||||
c := spec.Containers[0]
|
||||
if len(c.Command) != 2 || c.Command[0] != felisBinaryPath || c.Command[1] != "files" {
|
||||
t.Fatalf("command = %v, want [%s files]", c.Command, felisBinaryPath)
|
||||
}
|
||||
args := strings.Join(c.Args, " ")
|
||||
for _, want := range []string{"--op read", "--path server.properties", "--worlds-root /data"} {
|
||||
if !strings.Contains(args, want) {
|
||||
t.Fatalf("args %q missing %q", args, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFilesJobWorldMountIsReadOnlyExceptForWrite pins the guarantee that only a
|
||||
// write can mutate a world. For list and read the kernel refuses the write, not
|
||||
// merely the code — a defence that survives a bug in the entrypoint.
|
||||
func TestFilesJobWorldMountIsReadOnlyExceptForWrite(t *testing.T) {
|
||||
cases := []struct {
|
||||
op string
|
||||
wantReadOnly bool
|
||||
}{
|
||||
{OpList, true},
|
||||
{OpRead, true},
|
||||
{OpWrite, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.op, func(t *testing.T) {
|
||||
job, err := FilesJob(testParams(tc.op))
|
||||
if err != nil {
|
||||
t.Fatalf("FilesJob: %v", err)
|
||||
}
|
||||
spec := job.Spec.Template.Spec
|
||||
gotMount := spec.Containers[0].VolumeMounts[0].ReadOnly
|
||||
gotVol := spec.Volumes[0].PersistentVolumeClaim.ReadOnly
|
||||
if gotMount != tc.wantReadOnly || gotVol != tc.wantReadOnly {
|
||||
t.Fatalf("op %s: mount.readOnly=%v volume.readOnly=%v, want %v",
|
||||
tc.op, gotMount, gotVol, tc.wantReadOnly)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesJobContentEnv pins the write channel: content rides the Job spec
|
||||
// base64-encoded, and ONLY for a write — a list or read Job spec must carry no
|
||||
// caller content at all.
|
||||
func TestFilesJobContentEnv(t *testing.T) {
|
||||
t.Run("write carries base64 content", func(t *testing.T) {
|
||||
p := testParams(OpWrite)
|
||||
p.Content = []byte("motd=hello\n\x00\xff")
|
||||
job, err := FilesJob(p)
|
||||
if err != nil {
|
||||
t.Fatalf("FilesJob: %v", err)
|
||||
}
|
||||
env := job.Spec.Template.Spec.Containers[0].Env
|
||||
if len(env) != 1 || env[0].Name != ContentEnv {
|
||||
t.Fatalf("env = %+v, want exactly %s", env, ContentEnv)
|
||||
}
|
||||
got, err := base64.StdEncoding.DecodeString(env[0].Value)
|
||||
if err != nil {
|
||||
t.Fatalf("env value is not base64: %v", err)
|
||||
}
|
||||
if string(got) != string(p.Content) {
|
||||
t.Fatalf("decoded %q, want %q — arbitrary bytes must survive", got, p.Content)
|
||||
}
|
||||
// The content must never leak into argv, which is world-readable on the node.
|
||||
if strings.Contains(strings.Join(job.Spec.Template.Spec.Containers[0].Args, " "), "motd=hello") {
|
||||
t.Fatal("content must not appear in the container arguments")
|
||||
}
|
||||
})
|
||||
|
||||
for _, op := range []string{OpList, OpRead} {
|
||||
t.Run(op+" carries no content env", func(t *testing.T) {
|
||||
job, err := FilesJob(testParams(op))
|
||||
if err != nil {
|
||||
t.Fatalf("FilesJob: %v", err)
|
||||
}
|
||||
if env := job.Spec.Template.Spec.Containers[0].Env; len(env) != 0 {
|
||||
t.Fatalf("env = %+v, want none for a %s", env, op)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesJobNameIsPerInvocation is the RBAC-forced property documented on
|
||||
// FilesJobName. felis-api holds jobs:create and NOTHING else — no jobs:delete — so
|
||||
// a deterministic name would let the first completed Job squat it for a whole TTL
|
||||
// window and wedge every subsequent operation. Two operations on the same server
|
||||
// must therefore never collide.
|
||||
func TestFilesJobNameIsPerInvocation(t *testing.T) {
|
||||
a := testParams(OpRead)
|
||||
b := testParams(OpRead)
|
||||
b.OpID = "deadbeefcafe0002"
|
||||
|
||||
ja, err := FilesJob(a)
|
||||
if err != nil {
|
||||
t.Fatalf("FilesJob: %v", err)
|
||||
}
|
||||
jb, err := FilesJob(b)
|
||||
if err != nil {
|
||||
t.Fatalf("FilesJob: %v", err)
|
||||
}
|
||||
if ja.Name == jb.Name {
|
||||
t.Fatalf("two operations on one server share the Job name %q — the editor would wedge", ja.Name)
|
||||
}
|
||||
if !strings.Contains(ja.Name, "survival") || !strings.Contains(ja.Name, a.OpID) {
|
||||
t.Fatalf("job name %q should carry the server and the op id", ja.Name)
|
||||
}
|
||||
// The op id must also label the Pod, or the runner could not select THIS
|
||||
// operation's Pod to read its result from.
|
||||
if got := ja.Spec.Template.ObjectMeta.Labels[LabelOpID]; got != a.OpID {
|
||||
t.Fatalf("pod label %s = %q, want %q", LabelOpID, got, a.OpID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilesJobRejectsBadParams checks the renderer fails loudly rather than
|
||||
// producing a Job that cannot run or that would be refused by etcd.
|
||||
func TestFilesJobRejectsBadParams(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*JobParams)
|
||||
}{
|
||||
{"no image", func(p *JobParams) { p.Image = "" }},
|
||||
{"no world PVC", func(p *JobParams) { p.WorldPVC = "" }},
|
||||
{"no op id", func(p *JobParams) { p.OpID = "" }},
|
||||
{"unknown op", func(p *JobParams) { p.Op = "delete" }},
|
||||
{"oversized content", func(p *JobParams) {
|
||||
p.Op, p.Content = OpWrite, make([]byte, MaxWriteBytes+1)
|
||||
}},
|
||||
{"bad cpu limit", func(p *JobParams) { p.CPULimit = "half" }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := testParams(OpRead)
|
||||
tc.mutate(&p)
|
||||
if _, err := FilesJob(p); err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package fileedit
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
|
||||
// pollInterval is how often the runner re-Lists Pods while waiting for the file
|
||||
// Job to finish. It is a POLL rather than a Watch because felis-api holds
|
||||
// pods:list and NOT pods:watch (internal/platform.APIMinecraftRole) — establishing
|
||||
// a watch would need a permission this design exists to avoid. Half a second is
|
||||
// well inside the human-perceptible floor for an operation already dominated by
|
||||
// Pod scheduling, while keeping the request count on a slow image pull modest.
|
||||
const pollInterval = 500 * time.Millisecond
|
||||
|
||||
// maxLogBytes bounds what the runner will buffer from a Pod's log. The payload is
|
||||
// at most a base64-encoded MaxReadBytes (≈4/3 of 1 MiB) plus the JSON envelope, so
|
||||
// 4 MiB is generous headroom while still refusing to let a Pod that floods stderr
|
||||
// pull felis-api's memory down with it.
|
||||
const maxLogBytes = 4 << 20
|
||||
|
||||
// K8sRunner is the production Runner (spec §7, §16). It drives one file operation
|
||||
// end to end using ONLY the three permissions felis-api already holds in the
|
||||
// minecraft namespace, which is the entire point of the design:
|
||||
//
|
||||
// jobs:create → create the file Job
|
||||
// pods:list → find its Pod and observe the phase (no pods:get, no pods:watch)
|
||||
// pods/log:get → read the printed result back
|
||||
//
|
||||
// It deliberately takes a typed kubernetes.Interface rather than the
|
||||
// controller-runtime client that internal/restore's K8sJobs uses: the log
|
||||
// subresource (GetLogs(...).Stream) exists only on the typed CoreV1 client, and
|
||||
// the Job create is available on both — so one client covers all three calls
|
||||
// instead of the binding carrying two.
|
||||
//
|
||||
// INTEGRATION-ONLY: like K8sLogStreamer and K8sCluster this needs a live cluster;
|
||||
// it compiles here but is exercised only against one, never by the hermetic test
|
||||
// suite. The Oracle verifies the layer above it (Editor orchestration and error
|
||||
// mapping) against a fake Runner, and the Job shape via the pure jobspec.
|
||||
type K8sRunner struct {
|
||||
cs kubernetes.Interface
|
||||
}
|
||||
|
||||
// NewK8sRunner builds a Runner over cs. Every per-operation parameter — the
|
||||
// namespace included — travels in the JobParams the Editor renders, so there is no
|
||||
// Config to retain here.
|
||||
func NewK8sRunner(cs kubernetes.Interface) *K8sRunner {
|
||||
return &K8sRunner{cs: cs}
|
||||
}
|
||||
|
||||
// Run creates the file Job, waits for its Pod to reach a terminal phase, and
|
||||
// returns the JSON payload from the ResultPrefix line of that Pod's log.
|
||||
//
|
||||
// The Job name carries a fresh random OpID (FilesJobName), so a create collision is
|
||||
// not an expected condition the way it is for restore — an AlreadyExists here means
|
||||
// a 64-bit collision inside one TTL window and is reported rather than absorbed,
|
||||
// because absorbing it would mean returning ANOTHER operation's output.
|
||||
func (k *K8sRunner) Run(ctx context.Context, p JobParams) ([]byte, error) {
|
||||
job, err := FilesJob(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := k.cs.BatchV1().Jobs(p.Namespace).Create(ctx, job, metav1.CreateOptions{}); err != nil {
|
||||
return nil, fmt.Errorf("fileedit: create file job: %w", err)
|
||||
}
|
||||
|
||||
pod, err := k.awaitPod(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log, err := k.podLog(ctx, p.Namespace, pod.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payload, ok := extractResult(log)
|
||||
if !ok {
|
||||
// No marked line: the entrypoint died before printing (an unmountable volume,
|
||||
// an OOM kill, a deadline). The log tail travels in the error for the operator's
|
||||
// benefit — this error reaches felis-api's logs, while the caller gets the
|
||||
// generic 500 writeError produces, so no node detail leaks to the browser.
|
||||
return nil, fmt.Errorf("fileedit: file job %s produced no result (phase %s): %s",
|
||||
job.Name, pod.Status.Phase, tail(log))
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// awaitPod polls until the operation's Pod reaches a terminal phase. It selects by
|
||||
// the per-invocation LabelOpID, so it can never observe a different operation's Pod
|
||||
// — the reason that label exists.
|
||||
//
|
||||
// Both Succeeded and Failed are terminal and BOTH return the Pod rather than an
|
||||
// error, because a caller-fault result (a path that escapes the root, a file that
|
||||
// is too large) is printed and then exited on cleanly, and even a genuinely failed
|
||||
// Pod may have printed a diagnosable result first. Deciding what the outcome MEANS
|
||||
// is the caller's job (Run reads the printed result); this function only decides
|
||||
// when there is nothing left to wait for.
|
||||
func (k *K8sRunner) awaitPod(ctx context.Context, p JobParams) (*corev1.Pod, error) {
|
||||
ticker := time.NewTicker(pollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
pods, err := k.cs.CoreV1().Pods(p.Namespace).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: LabelOpID + "=" + p.OpID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fileedit: find file job pod: %w", err)
|
||||
}
|
||||
for i := range pods.Items {
|
||||
switch pods.Items[i].Status.Phase {
|
||||
case corev1.PodSucceeded, corev1.PodFailed:
|
||||
return &pods.Items[i], nil
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-ctx.Done():
|
||||
// The Editor's Timeout (or the client disconnecting) fired. The Job is left
|
||||
// alone deliberately: felis-api holds no jobs:delete, and the Job's own
|
||||
// activeDeadlineSeconds plus ttlSecondsAfterFinished retire it without help.
|
||||
return nil, fmt.Errorf("fileedit: timed out waiting for the file job to finish: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// podLog reads a finished Pod's log. Follow is off — the Pod has already
|
||||
// terminated, so the log is complete and a follow would merely block until the
|
||||
// stream closed.
|
||||
func (k *K8sRunner) podLog(ctx context.Context, namespace, pod string) (string, error) {
|
||||
stream, err := k.cs.CoreV1().Pods(namespace).GetLogs(pod, &corev1.PodLogOptions{
|
||||
Container: containerName,
|
||||
}).Stream(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fileedit: read file job log: %w", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
b, err := io.ReadAll(io.LimitReader(stream, maxLogBytes))
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return "", fmt.Errorf("fileedit: read file job log: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// extractResult finds the marked payload in a Pod log. It scans for the LAST line
|
||||
// carrying ResultPrefix because pods/log returns stdout and stderr MERGED: a Go
|
||||
// runtime warning or a libc message can appear anywhere in the stream, so the
|
||||
// payload must be located by its marker rather than by position. Taking the last
|
||||
// match rather than the first is the conservative choice — if a marker somehow
|
||||
// appeared more than once, the final one is the operation's actual outcome.
|
||||
func extractResult(log string) ([]byte, bool) {
|
||||
var payload string
|
||||
var found bool
|
||||
sc := bufio.NewScanner(strings.NewReader(log))
|
||||
sc.Buffer(make([]byte, 0, 64*1024), maxLogBytes)
|
||||
for sc.Scan() {
|
||||
if rest, ok := strings.CutPrefix(sc.Text(), ResultPrefix); ok {
|
||||
payload, found = rest, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, false
|
||||
}
|
||||
return []byte(payload), true
|
||||
}
|
||||
|
||||
// tail returns the last few hundred bytes of a log for an error message, so a
|
||||
// diagnostic is useful without embedding an entire log in an error string.
|
||||
func tail(log string) string {
|
||||
const n = 512
|
||||
log = strings.TrimSpace(log)
|
||||
if len(log) <= n {
|
||||
return log
|
||||
}
|
||||
return "..." + log[len(log)-n:]
|
||||
}
|
||||
Reference in new issue
Block a user