Skip to content

feat: Phase 6 — push sources, upload path and Paper plugin - #8

Closed
TheMeinerLP wants to merge 17 commits into
feat/phase-5b-uifrom
feat/phase-6-push
Closed

feat: Phase 6 — push sources, upload path and Paper plugin#8
TheMeinerLP wants to merge 17 commits into
feat/phase-5b-uifrom
feat/phase-6-push

Conversation

@TheMeinerLP

@TheMeinerLP TheMeinerLP commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

The last phase of the Apus spec. Stacked on PR #7.

Until now Apus pulled worlds — from an S3 bucket or a Pterodactyl panel. This adds the reverse: a running Paper server pushes its own world, and a user can upload one through the UI.

What this delivers

  • paper-worldpush — a Paper plugin that copies a live world consistently (pause autosave, save once, copy, resume) and incrementally, uploading only region files whose mtime or checksum changed. All copying and uploading happens off the server thread; only the save itself touches the main thread, because Bukkit requires it.
  • Push and upload connectors in the ingest module, plus the wiring so those source types actually run.
  • POST /api/uploads with presigned multipart, so world files never travel through the API.
  • POST /api/push/{token} — the one endpoint that authenticates with a tenant-bound service token rather than a user login, because a server plugin must not stop working when a person leaves the team.
  • Push tokens are now provisioned by the operator: cryptographically random, one per tenant, written only into a Secret. Never in status, events or logs — status carries the Secret's name at most.

Upload restrictions, measured rather than claimed

A presigned URL is a transferable credential: what it permits, it permits to anyone holding it. So these were verified empirically against real MinIO rather than asserted.

  • Prefix confinement is structural — the S3 key is a pure function of the server-derived tenant namespace, never client input.
  • Redirecting a presigned part to a different key → 403 SignatureDoesNotMatch.
  • Exceeding the declared part size → 403. This was the one expected to be weak; it is not.
  • Total size cap is enforced at completion via ListParts, never presigned — an oversized upload is aborted and never becomes a readable object.

Caveat stated plainly: verified against MinIO, not independently against Ceph RGW. Same SigV4 mechanism, but that is an inference, not a second measurement.

An unresolved design conflict, stated rather than hidden

The plugin pushes many individual raw region files incrementally. The staged connector expects one object per version. The HTTP call between them is now correct, but an ingest triggered that way would likely find nothing usable.

The upload path (single archive) works end to end and is proven by PushIngestEndToEndTest against real MinIO. The plugin path needs its own design pass.

This surfaced because the plugin and the API endpoint were built simultaneously in separate worktrees — each side's assumption was reasonable, and neither could see the other's.

Also in this PR

  • The design spec now opens with a "Stand der Umsetzung" section: which phases shipped, that sharding was deliberately not built after its spike, and what is knowingly open.
  • Spec corrections: several modules still said Java 21 where everything is on 25, and several "open points" in section 15 had been resolved during implementation.
  • Fixed a stale assertion in the ingest/render contract test. It listed expected bundle objects exactly and broke once level.dat was legitimately added by the writer. The check now requires every mandatory object and still rejects genuinely unexpected ones, without breaking on spec-documented sidecars.

Known gaps

  • RBAC for the API's token lookup is broader than ideal (cluster-wide Secret read); a narrower path is documented but not implemented. No Helm or Kustomize manifests exist in the repo, so RBAC lives in javadoc only.
  • The plugin's save window — pause autosave, save, copy, resume — is untested; that needs a real Paper server, which spec section 13.2 already concedes.
  • The OIDC flow has still never run against a real broker; none is chosen (spec section 15).

A running Paper server can now push its own world to Apus instead of
only being polled: paper-worldpush pauses autosave, forces one save,
then copies and uploads only region files whose mtime or checksum
actually changed since the last cycle, entirely off the main thread
via Paper's AsyncScheduler/GlobalRegionScheduler. Uploads land in a
per-tenant S3 staging prefix, authenticated with a tenant-bound
world:push service token kept in the plugin's own config rather than
tied to any user login, and a completion report goes to the Apus API.

New module, own release track (pinned to paper-api 26.2.build.111,
independent of this repo's own version line, matching telemetry-addon's
existing precedent for foreign-version dependencies).
Both push-style sources (paper-worldpush writing directly, the UI's
presigned multipart upload) stage their payload as a single object
under a prefix in S3 before an ingest starts, so unlike S3SourceConnector
neither reports versions via discover(). PushSourceConnector and
UploadSourceConnector share that fetch logic through a new
AbstractStagedSourceConnector; only the WorldSourceSpec.type
discriminator differs between them.

Covered by MinIO-backed tests (Testcontainers), excluded from the
default test task and run via :ingest:integrationTest like the
existing S3SourceConnectorTest.
POST /api/uploads initiates a presigned S3 multipart upload into a
tenant-scoped staging prefix (design spec §11.1): CreateMultipartUpload,
ListParts, CompleteMultipartUpload and AbortMultipartUpload all run
backend-side with the platform's own staging credentials, never
presigned -- only UploadPart is. POST /api/uploads/{uploadId}/complete
finalises it, summing the real S3-recorded part sizes via ListParts and
aborting rather than completing an upload whose actual total exceeds
the configured maximum. The staged object's key is always derived from
the caller's JWT-resolved namespace, never from request input.

POST /api/push/{token} is the one endpoint in this module that
authenticates via a tenant-bound service token instead of a JWT, per
design spec §10.3 -- looked up against labelled Secrets and compared
constant-time (MessageDigest.isEqual, exhaustive scan, no early return)
so neither the token nor a tenant's existence leaks through timing.
Creates one WorldIngest per configured world on the target push source,
mirroring WorldSourceReconciler's per-world loop for pull sources.

Adds the AWS SDK v2 dependency to the api module (S3Presigner ships
inside the s3 artifact itself in this SDK version, not a separate
s3-presigner module).
PushSourceConnector and UploadSourceConnector existed but IngestConfig
still rejected APUS_SOURCE_TYPE=push/upload outright, so an ingest of
either type could never start. Add both to the supported source types,
select the matching connector in IngestMain, and add the shared staging
env-var contract (APUS_SOURCE_STAGING_*) both connectors need.

Proven end to end against real MinIO with a new PushIngestEndToEndTest:
stage a world archive in a staging prefix, run IngestMain for push and
upload, assert a valid bundle and manifest come out the other side.
Nothing created the Secret FabricPushTokenRepository validates push
tokens against, so the push path was only usable by hand. Tokens are
tenant-bound, not per-WorldSource (design spec §10.3, and the existing
resolveNamespace already assumes this): TenantReconciler now creates
one cryptographically random, URL-safe token per tenant, in a fixed-name
Secret alongside the tenant's namespace, and never regenerates it on
later reconciles so an already-configured paper-worldpush server never
gets silently locked out. The raw token never appears in status, an
event, or a log line -- only the Secret's fixed, non-secret name does.

api's FabricPushTokenRepository now shares the Secret-shape constants
with the operator instead of duplicating them, and its Javadoc documents
the RBAC trade-off the current cluster-wide, label-scoped lookup implies,
plus a narrower alternative left as a follow-up.
HttpPushNotifier sent {tenant, worldName, fileCount, bytesUploaded} as
the completion report body, but PushController/PushReportRequest only
ever deserialize {sourceName, version} -- every real push report from
this plugin would have been rejected with 400, only ever noticeable in
production. The token-as-path-segment transport itself was already
correct on both sides; only config.yml's comment wrongly called it a
bearer token, fixed too.

Adds the required world-source-name config key (a tenant may run more
than one push source, so the tenant-bound token alone can't pick one),
generates a per-cycle version identifier, and sends exactly the two
fields the API contract expects. New HttpPushNotifierTest locks the
wire shape in against a local HTTP stub.
…tract test

IngestRenderContractTest never set APUS_BUNDLE_SOURCE_NAME, a required
IngestConfig field since before phase 6, so 🏃integrationTest
failed at the very first assertion whenever it actually ran. Add it and
fold it into the expected bundle path, matching BundlePath's real
tenant/sourceName/worldId/version shape. Unrelated to the phase 6 push
path; found while verifying IngestConfig's other callers.
- New "Stand der Umsetzung" section up front: all six phases built,
  sharding deliberately not built after the phase 4 spike, and the
  three remaining open points (identity broker unselected, OIDC never
  tested against a real broker, paper-worldpush's save window
  untested).
- §4 module table: Java 21 -> 25 everywhere (the root toolchain applies
  uniformly), world-ingest/runner-image -> the actual ingest/runner
  directory names, added hosting, corrected operator's stack (JOSDK +
  fabric8, no Micronaut) and api/ui to their current form.
- §13.2: CRD generation marked done, per-module test coverage
  corrected.
- §15: connector order and CRD generation marked resolved; bucket
  notifications corrected (a direct completion callback was built
  instead, not notifications or polling); two new open items (Paper
  save window, push-token RBAC broader than ideal).
Closes out the phase 6 push-source work: the ingest wiring, tenant
push-token provisioning, and the paper-worldpush/api request-contract
fix, plus verification results and remaining concerns.
BundleWriter has written level.dat (and, where present, per-dimension
entities/poi) as part of every bundle since phase 2b, exactly as the
design spec documents. IngestRenderContractTest's bucket-listing
assertion still expected only the six region files plus manifest.json,
so it failed on the now-legitimate extra level.dat object.

Replace the exact-set comparison with two checks: every mandatory
object (manifest.json, every region file) must still be present, and
anything beyond that must match the spec-documented sidecar content
(level.dat, entities/, poi/) rather than being an unconstrained
allow-everything check. This keeps the test's power to catch missing
or stray objects while not breaking again the next time a fixture
exercises entities/poi.
…f hardcoding them

Same fix as feat/phase-1-render-kern's MinioFixtures and
feat/phase-2b-ingest's S3SourceConnectorTest: these three container-based
integration tests (introduced in this phase's push/upload feature work)
hardcoded the well-known MinIO default access/secret key pair. Generate a
fresh, random pair per test run instead, long enough to satisfy MinIO's own
minimum key lengths.
…onfig

The strings the secret scanner flagged were not only in old commits: the
phase 1 plan carried them in a code sample, and .gitguardian.yaml listed
them in plaintext, which made the exemption file a finding of its own.

The plan sample now shows a placeholder, and the exemptions are SHA256
digests.
@TheMeinerLP

Copy link
Copy Markdown
Contributor Author

Closing in favor of #16: same content, rebuilt as a single squash commit on a new clean/* branch because a secret scanner flagged disposable test credentials in this PR's commit history and history cannot be rewritten in this environment. See #16 for detail.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant