feat(files): 文件管理可新建、建目录、删除、重命名和上传,写入内容拆成多个环境变量不再超内核单变量上限

This commit is contained in:
Lemon-miaow committed 2026-09-27 19:58:28 +08:00
1 parent b6751eac0f
commit 051cc1c9f5
37 files changed
+5972 -416

No files matched your search

+323 -1
View File
@@ -1202,6 +1202,38 @@ paths:
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/internal/file-uploads/{id}:
get:
tags: [files]
operationId: internalFileUpload
summary: Stream one staged file upload to the Job landing it (one-time bearer token).
description: >-
PUT /api/v1/servers/{name}/files/upload stages the body on felis-api's disk
and creates a Job to land it in the world volume; the Job fetches the bytes
here. The Job holds no service token, so the route is public on the internal
face and the bearer token minted with the upload is the whole check. The
token opens its upload once. An unknown id, a wrong or missing token and a
spent token are all the same 404, so the route says nothing about which
uploads exist.
x-felis-face: [internal]
x-felis-tier: public
security: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- name: Authorization
in: header
required: true
description: Bearer followed by the token minted with the upload.
schema: { type: string }
responses:
'200':
description: The staged bytes, verbatim, with their Content-Length.
content:
application/octet-stream:
schema: { type: string, format: binary }
'404':
$ref: '#/components/responses/NotFound'
/api/v1/internal/servers/{name}/join-event:
post:
tags: [servers-internal]
@@ -4224,6 +4256,12 @@ paths:
The sha256 a read returned. When present, the write is refused with
409 file_changed if the file has changed (or been deleted) since.
Omit it to write unconditionally.
create_only:
type: boolean
description: >-
true writes only if nothing is at the path yet (409 file_exists
otherwise), for making a new file without replacing one that
appeared meanwhile. Cannot be combined with expect_sha256.
responses:
'200':
description: File written.
@@ -4251,7 +4289,11 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: Server is not stopped (not_stopped), or a restore, backup or file write already holds its world volume (maintenance_in_progress).
description: >-
The file changed since expect_sha256 was read (file_changed), something is
already at the path with create_only (file_exists), the server is not
stopped (not_stopped), or a restore, backup or file change already holds its
world volume (maintenance_in_progress).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
@@ -4272,6 +4314,286 @@ paths:
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
delete:
tags: [files]
operationId: deleteServerFile
summary: Delete a file or folder in a server's world volume (owner-or-admin; server must be stopped).
description: >-
Deletes a file, a symlink (never what it points at) or a folder with
everything in it. The world root itself is refused (400 bad_path). Same
stopped-gate, world lock and os.Root containment as a write. The panel
confirms first; this route does not. Audited as file.delete.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: path
in: query
required: true
description: File or folder to delete, relative to the world root.
schema: { type: string }
responses:
'200':
description: Deleted.
content:
application/json:
schema:
type: object
required: [path, status]
properties:
path: { type: string }
status: { type: string, const: deleted }
'400':
description: Missing path, invalid server name, the world root, or a path that escapes it.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server, or nothing at the path.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: Server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
'504':
description: The file Job did not finish in time; retry.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/servers/{name}/files/mkdir:
post:
tags: [files]
operationId: makeServerFolder
summary: Make a folder in a server's world volume (owner-or-admin; server must be stopped).
description: >-
Makes one folder. Its parent must already exist (404), and nothing may be at
the path yet (409 file_exists). Same stopped-gate, world lock and os.Root
containment as a write. Audited as file.mkdir.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: path
in: query
required: true
description: Folder to make, relative to the world root.
schema: { type: string }
responses:
'200':
description: Folder made.
content:
application/json:
schema:
type: object
required: [path, status]
properties:
path: { type: string }
status: { type: string, const: created }
'400':
description: Missing path, invalid server name, the world root, or a path that escapes it.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server, or the parent folder does not exist.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: Something is already at the path (file_exists), the server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
'504':
description: The file Job did not finish in time; retry.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/servers/{name}/files/rename:
post:
tags: [files]
operationId: renameServerFile
summary: Move or rename a file or folder in a server's world volume (owner-or-admin; server must be stopped).
description: >-
Moves the file or folder at path to to. It never replaces: an existing
destination is 409 file_exists, and a missing destination folder is 404.
server.properties, config/paper-global.yml and config/ cannot be moved under
any name they are reached by (400 bad_path), because elsewhere the read path
would no longer withhold their secrets. Same stopped-gate, world lock and
os.Root containment as a write. Audited as file.rename.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: path
in: query
required: true
description: File or folder to move, relative to the world root.
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [to]
properties:
to: { type: string, minLength: 1, description: The new path, relative to the world root. }
responses:
'200':
description: Moved.
content:
application/json:
schema:
type: object
required: [path, to, status]
properties:
path: { type: string }
to: { type: string }
status: { type: string, const: renamed }
'400':
description: Missing path or to, malformed body, invalid server name, the world root, a file felis manages, or a path that escapes the world root.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server, nothing at path, or the destination folder does not exist.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: Something is already at to (file_exists), the server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
'504':
description: The file Job did not finish in time; retry.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/servers/{name}/files/upload:
put:
tags: [files]
operationId: uploadServerFile
summary: Upload a file into a server's world volume (owner-or-admin; server must be stopped).
description: >-
Lands the raw request body as the file at path, up to 64 MiB — a plugin jar,
a datapack, a world region. Content-Length is required (411
length_required). An existing file is 409 file_exists unless overwrite=true;
a folder at the path is 400 bad_path either way. The body is staged on
felis-api's disk first and then fetched by the file Job with a one-time
token, so the world lock is taken only after the body has arrived and a slow
upload holds off no backup. The file lands atomically: a synced temporary
sibling is checked against the staged size and SHA-256, then renamed into
place, so a failed upload leaves the old file whole. Same stopped-gate and
os.Root containment as a write. Audited as file.upload.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: path
in: query
required: true
description: File to create, relative to the world root. Its folder must exist.
schema: { type: string }
- name: overwrite
in: query
required: false
description: true replaces an existing file, keeping its mode. Anything else refuses to.
schema: { type: string, enum: ["true", "false"] }
requestBody:
required: true
content:
application/octet-stream:
schema: { type: string, format: binary }
responses:
'200':
description: File uploaded.
content:
application/json:
schema:
type: object
required: [path, status, sha256, size]
properties:
path: { type: string }
status: { type: string, const: uploaded }
sha256: { type: string, pattern: '^[0-9a-f]{64}$', description: SHA-256 of the bytes landed. }
size: { type: integer, format: int64, description: Bytes landed. }
'400':
description: >-
Missing path, invalid server name, a folder or the world root at the path,
a path that escapes the world root, or a body that ended before
Content-Length bytes arrived (upload_incomplete).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server, or the folder does not exist.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: A file is already at the path and overwrite is not true (file_exists), the server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'411':
description: The request has no Content-Length (length_required).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'413':
description: The file is over 64 MiB (too_large).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
'504':
description: The file Job did not finish in time; retry.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'507':
description: >-
felis-api's staging disk has no room for the upload right now
(upload_staging_full), or the world volume has no room for it
(volume_full); nothing was changed.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
# ------------------------------------------------------ users (admin tier) ----
/api/v1/users:
+77 -7
View File
@@ -220,7 +220,7 @@ per-server cooldown → global running cap**. Map the API result:
| HTTP | Code | Cause | Fix |
|---|---|---|---|
| `403` | `forbidden` | `autostartPolicy=allowlist` and UUID not allowlisted, or `ownerOnly` and caller is not owner | Add the UUID / claim the server / set `autostartPolicy=public` |
| `409` | `maintenance_in_progress` | A restore, backup or file write holds the server's world volume (§3b) | Wait for the Job to finish |
| `409` | `maintenance_in_progress` | A restore, backup or file change holds the server's world volume (§3b) | Wait for the Job to finish |
| `409` | `world_reclaiming` | The idle reaper is archiving the world (§3b item 3); afterwards the server is released with an empty world | Nothing to wait for; the old world stays in the archive |
| `429` | (cooldown) | Wake retried within the 30s per-server `WakeCooldown` | Wait out the cooldown |
| `503` | `at_capacity` | Global `MaxRunningServers` cap reached | Stop another server or raise the cap |
@@ -238,11 +238,11 @@ shortly.") lives in the Java plugin and is **[CODE-ONLY]** — the codes it reac
to are produced by the Go-tested `authorizeWakeByUUID` / cooldown limiter, so
grade the two halves separately.
### 3b. Wake, restore, backup or file save refused with `maintenance_in_progress`
### 3b. Wake, restore, backup or file change refused with `maintenance_in_progress`
A server's world volume is ReadWriteOnce, and on a single node RWO lets a game
pod and a restore Job mount it side by side. So felis-api serialises them per
server: a restore, a backup, or a file write takes the world, and until its Job
server: a restore, a backup, or a file change takes the world, and until its Job
finishes every wake (panel or join) and every other world operation on that
server gets `409 maintenance_in_progress`. File reads and listings never hold
it. The operator applies the same rule when `desiredState` is flipped to
@@ -253,7 +253,8 @@ What holds the world, in order:
1. An unfinished Job labelled `felis.lolicon.best/server=<name>` with
`app.kubernetes.io/managed-by` `felis-restore`, `felis-backup`, or
`felis-files` plus `felis.lolicon.best/files-mode=write`:
`felis-files` with any `felis.lolicon.best/files-mode` but `list` or `read`
(a save, new file, new folder, rename, delete or upload; §18):
```sh
kubectl -n minecraft get jobs -l felis.lolicon.best/server=<name>
@@ -281,7 +282,7 @@ What holds the world, in order:
the lock before it deletes the world volume, keeps the world and retries the
next day.
A restore, backup or file write refused with `409 not_stopped` although the
A restore, backup or file change refused with `409 not_stopped` although the
panel shows `Stopped` means the game pod is still terminating (its shutdown save
can take a while); retry once `kubectl -n minecraft get pods -l
felis.lolicon.best/server=<name>` shows nothing.
@@ -465,7 +466,7 @@ installer (`sudo bash deploy/bootstrap.sh`) puts the value everywhere. [GO-TESTE
stale. A proxy on another host is left alone: set `service-token` in its
`felis-link.properties` to the value in Secret `felis/felis-service-token`, and
it takes it within a few seconds.
- **forwarding** — a server whose world a backup, restore or file write holds is
- **forwarding** — a server whose world a backup, restore or file change holds is
left running and named in the plan and the output; players cannot join it until
it restarts, so stop and start it from the panel once that finishes. The next
installer run restarts the proxy once more (its record of what the proxy was
@@ -970,7 +971,7 @@ The reap sequence (all [GO-TESTED] hermetically) preserves the world unless a
0. The world must be at rest before it is archived. A server still meant to
run is told to stop (`desiredState: Stopped`) and left for the next run; one
still stopping, whose game pod still exists, or whose world a restore,
backup or file write holds is left too. Each of these counts in
backup or file change holds is left too. Each of these counts in
`awaiting_stop=` and does not fail the run. Once the server is down, the
reaper takes the world's maintenance lock (§3b) and holds it through the
archive and the volume delete: nothing can start the server or touch its
@@ -2990,6 +2991,74 @@ for 10 seconds (the Free plan's limits).
---
## 18. Server files: a change or an upload is refused
The panel's Files page is for the server's owner or an admin, and only while
the server is fully stopped. Each call runs a one-shot `felis files` Job in the
`minecraft` namespace, labelled `app.kubernetes.io/managed-by=felis-files` and
`felis.lolicon.best/files-mode=<list|read|write|mkdir|delete|rename|upload>`.
A listing or a read holds nothing. Every change (a save, a new file or folder,
a rename, a delete, an upload) holds the world for its Job (§3b), so a wake or a
second change in the meantime gets `409 maintenance_in_progress`. The panel
sends uploads one at a time and greys its other changes until they finish.
| Status | Code | Meaning | What to do |
|---|---|---|---|
| `409` | `not_stopped` | The game pod is still there, usually finishing its shutdown save | Retry once `kubectl -n minecraft get pods -l felis.lolicon.best/server=<name>` shows nothing |
| `409` | `maintenance_in_progress` | Another change, a backup, a restore or the reaper holds the world | §3b |
| `409` | `file_exists` | A new file, new folder, rename, or upload sent without replace found something at the path | Pick another name or clear the path; the panel offers Replace for an upload |
| `409` | `file_changed` | The file changed after the editor read it | The editor offers to load the latest or overwrite it |
| `400` | `bad_path` | The path leaves the world volume (`..`, an absolute path, a link pointing out), or it would move `server.properties`, `config` or `config/paper-global.yml`, or read `config/paper-global.yml` | Those three keep their names: the read path withholds their secrets by name, and `paper-global.yml` holds the proxy forwarding secret every server shares |
| `404` | `not_found` | The path, or a new folder's parent, is gone | Refresh the listing |
| `413` | `too_large` | A read over 1 MiB, a save over 256 KiB, or an upload over 64 MiB | Upload a large file whole instead of editing it |
| `411` | `length_required` | An upload without `Content-Length` (a chunked body) | Upload from the panel, or with `curl -T`, which sends the length |
| `400` | `upload_incomplete` | The body ended before its declared length | Retry; nothing was changed |
| `507` | `upload_staging_full` | Staging this upload would leave felis-api's staging filesystem under 10% free | Free space on the uploads volume |
| `507` | `volume_full` | The world volume ran out of space; the old file is left as it was | Delete files the server no longer needs, or grow its volume |
| `504` | `files_timeout` | felis-api stopped waiting after 90 s | See below: the Job may still finish |
| `503` | `files_unavailable` | felis-api runs without the file Job runner, or (for an upload) without a staging directory or its internal address | Check felis-api's startup log |
**An upload travels in two legs.** The browser sends the body to felis-api,
which stages it under `/var/lib/felis/uploads/.file-staging` on the uploads
volume (`felis-file-staging` in the pod's temp directory when that volume is not
mounted). The Job then fetches it once from felis-api's internal face,
`http://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/file-uploads/<id>`,
with a one-time token, checks the size and SHA-256, and lands it. The staged
copy is deleted once the Job has answered, and a felis-api restart empties the
directory. A Job that cannot fetch its upload, or fetches bytes that do not
match, fails and leaves the target as it was; the panel shows a server error
it can retry. The Job's log names the cause:
```sh
kubectl -n minecraft get jobs -l felis.lolicon.best/server=<name>,app.kubernetes.io/managed-by=felis-files
kubectl -n minecraft logs job/<job>
```
**After `files_timeout`** the Job runs on to its own two-minute deadline and may
still land the change. It keeps holding the world until it ends, so the next
change waits on it with `maintenance_in_progress`; refresh the listing once it
is gone to see whether the change landed. A Job is kept for two minutes after
it ends, with its log.
Every change is audited as `file.write`, `file.mkdir`, `file.delete`,
`file.rename` (with `to`) or `file.upload` (with `size_bytes`, `sha256` and
`overwrite`), with `server_name` set to `<server>:<path>`:
```sh
sudo k3s kubectl -n felis exec deploy/felis-postgres -c postgres -- psql -U postgres felis -c "
SELECT created_at, actor, action, server_name, payload
FROM audit_logs WHERE action LIKE 'file.%' ORDER BY created_at DESC LIMIT 20;"
```
[GO-TESTED: `internal/fileedit`, `handlers_files_test.go`, `cmd/felis/files_test.go`,
`internal/maintenance`.] [VM-TESTED: a pod labelled as a files Job in `minecraft`
reaches `felis-api-internal:8081`; a 256 KiB save's content, split across six
variables, lands byte for byte through the real binary, where one 140 KB variable
fails with `argument list too long`. An upload through the API, both legs end to
end, has not been run on a cluster.]
---
## Quick reference: symptom → section
| Symptom | Section |
@@ -3033,3 +3102,4 @@ for 10 seconds (the Free plan's limits).
| `FelisAuditWriteFailing` | §17 |
| `felis breakGlass` sends no code / shows `Root override`; `otp_skipped` in the audit | §17 |
| How long sessions, codes and audit rows are kept; export audit rows | §17 |
| Files page: a change or upload refused (`file_exists`, `bad_path`, `too_large`, `upload_staging_full`, `volume_full`, `files_timeout`) | §18 |