Skip to content

feat: Phase 2b — ingest and ETL layer - #3

Closed
TheMeinerLP wants to merge 32 commits into
feat/phase-2-operatorfrom
feat/phase-2b-ingest
Closed

feat: Phase 2b — ingest and ETL layer#3
TheMeinerLP wants to merge 32 commits into
feat/phase-2-operatorfrom
feat/phase-2b-ingest

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

Phase 2b of Apus: the ingest/ETL layer. Stacked on PR #2 (phase 2a), which must land first.

A WorldSource is now configured once and Apus takes it from there: it polls for new
world data, normalises whatever layout it finds into a versioned World Bundle in S3, and
the render path picks it up without knowing where the world came from.

The contract, proven end to end

The whole point of this layer is that the render path stays ignorant of world origins.
That was a claim until now. IngestRenderContractTest runs the real ingest against a
Bukkit-layout world in real MinIO, then renders the resulting bundle with the phase 1
runner image — and a tile lands in the map bucket. No production code had to change
for the contract to hold.

What this delivers

Component Role
LayoutDetector Recognises vanilla, Bukkit and nested layouts; normalises all of them to overworld/the_nether/the_end
BundleWriter Writes the bundle, manifest last — the commit point that makes half-unpacked worlds impossible without S3 transactions
S3SourceConnector, PterodactylConnector Fetch raw data; only this step is source-specific
WorldSource/WorldIngest CRDs + reconcilers Cron-driven polling, job orchestration, retention
ingest container image Runs the ETL as a Kubernetes Job

Adding a source later costs one connector: discover() and fetch(). Transform and
load are shared.

Security findings fixed in this branch

This is the layer that handles untrusted input — unpacked server backups and
uploaded archives — so the findings here matter more than elsewhere.

  • LayoutDetector followed symlinks and did not confine paths to the work root. A
    crafted archive with world/region -> /etc would have put system files into a bundle.
    Paths are now resolved and confined, symlinks rejected, world names validated.
  • Archive extraction had no size limits. A zip bomb could fill the node's disk and
    evict unrelated pods. Bounded by total size and entry count, and the job now declares
    ephemeral-storage.
  • Signed Pterodactyl download URLs leaked into logs through an error message. They
    are short-lived credentials for an entire server backup.
  • A WorldIngest could drive a foreign WorldSource — writing its status and
    deleting its bundles — because only the name was checked. Ownership is now verified by
    name and UID.

Two critical correctness bugs, both caught by review

An ingest could report success while its bundle stayed invisible. The reconciler took
the terminal phase from the pod log, but IngestMain logs phase=Succeeded just before
exiting — before the Job controller sets succeeded. A reconcile landing in that window
marked the resource terminal, so the completion handler never ran: no latestBundle, no
retention, no retry, and a complete bundle sitting in S3 that nothing referenced.
Terminality now comes only from Job status.

Retention could delete another source's live bundle. Bundle paths were keyed by
tenant and world name, so two sources with a world called world — the Minecraft default
— shared a prefix, and pruning crossed the boundary. Paths are now scoped by source, and
every source's latestBundle in the namespace is protected.

Known gaps

  • The Pterodactyl response envelope is the one part verified only against community docs,
    not panel source. It is now rejected if it does not match, instead of silently looking
    like "no backups available".
  • minecraftVersion comes from a new field on the world selector rather than from
    level.dat — the project carries no NBT dependency by design.

Testing

160 operator tests, 65 ingest tests, plus integration tests against real MinIO and k3s.
Container-based tests stay out of build: ./gradlew :ingest:integrationTest,
./gradlew :runner:integrationTest.

A re-review of the final fix wave is still outstanding — the two critical fixes were
verified directly, the remainder rests on the fix agent's own test evidence.

Adds the WorldSource and WorldIngest CRDs (bluemap.onelitefeather.net/v1alpha1,
namespaced) that Phase 2b's ingest layer builds on, plus the shared BundleRef
model. Every nested group is initialised in its field declaration and
initSpec()/initStatus() are overridden on both resources, avoiding the two
Phase 2a traps that previously blocked parallel work. Also sets up the new
ingest module with the AWS SDK v2 S3 client dependency.
Recognizes vanilla, bukkit, and nested (ZIP upload) directory layouts
for an ingested world and resolves each dimension to its region
directory. Falls back to a loud LayoutDetectionException, naming the
paths actually found, instead of guessing when no known layout
matches -- including when a forced layout kind does not fit the
structure on disk.
Writes region files for every dimension first and the manifest last,
so an interrupted write never leaves a manifest behind for an
incomplete bundle version. The manifest carries the region list per
dimension (read from r.<x>.<z>.mca file names), a SHA-256 checksum
over the uploaded bytes, and serialises to/from JSON via a small
self-contained codec (no new dependency). S3Client is a one-method
facade over the AWS SDK v2 client, narrow enough to fake in tests.
… escapes

LayoutDetector walks directory trees extracted from untrusted sources
(Pterodactyl backups, user-uploaded ZIPs). Validate the world name against
path separators and ".." segments, verify every candidate dimension path
resolves (via toRealPath) inside the given root, and reject symlinks
outright via NOFOLLOW_LINKS so a crafted archive can no longer point the
bundle writer at files outside the working directory.
… pull sources

Adds WorldSourceConnector/SourceVersion and two implementations: S3SourceConnector
(delimiter-scoped object listing per prefix, zip/tar.gz-aware fetch) and
PterodactylConnector (Client API backup listing + signed-URL download, verified
against the panel's own source on GitHub). The Pterodactyl backup is a tar.gz of
the whole server, so fetch() streams it exactly once through a hand-rolled tar
reader and writes only the configured world paths, never buffering the archive.

No archive or JSON library was available to this module's dependency set, so
Archives/TarStreamReader and MinimalJson were added as small, purpose-scoped
helpers rather than reaching for a new dependency.
…talog

Three parallel agents left the ingest module without a JSON library and
without the Testcontainers dependency, since none of them were allowed to
touch build files. BundleManifest and connector/MinimalJson each grew their
own hand-rolled JSON codec, and S3SourceConnectorTest drove MinIO through
the raw docker CLI instead.

Jackson is already pulled in transitively by fabric8's kubernetes-client
for the operator module; this is the first place it's declared explicitly.
Version 2.22.1 verified against Maven Central. Testcontainers catalog
entries already existed for operator/runner -- just add them here too.
BundleManifest and PterodactylConnector each carried their own
recursive-descent JSON parser because the module had no JSON library to
share. Both now use jackson-databind: BundleManifest (de)serialises
directly through its records via Jackson's built-in record support (no
annotations needed), and PterodactylConnector reads panel responses as
JsonNode instead of a hand-rolled Map<String,Object>. MinimalJson is gone.

BundleManifest keeps the same IllegalArgumentException contract on bad
input (non-object root, trailing content after the JSON value) by enabling
DeserializationFeature.FAIL_ON_TRAILING_TOKENS and translating
JsonProcessingException at the boundary. No test was weakened: the same
round-trip, human-readable-field, null-handling and rejection tests pass
unchanged against the new implementation.
S3SourceConnectorTest drove a MinIO container through ProcessBuilder and
the raw docker CLI because the module had no Testcontainers dependency.
Switch to org.testcontainers.containers.MinIOContainer, the same mechanism
operator and runner already use for their own container-based tests.
Testcontainers' Ryuk reaper removes the container even if a test crashes,
which the previous docker run/docker stop pairing could not guarantee.

Same real MinIO instance, same four tests, same assertions -- only the
container lifecycle mechanism changed.
TarStreamReader handles PAX path overrides (typeflag 'x') alongside the
GNU long-name mechanism (typeflag 'L'), but only the GNU case had a test.
Both extend a tar entry's path past the classic 100-byte ustar name field,
and a Pterodactyl backup of a server with a deep plugin/world tree
routinely contains both -- add the missing PAX case, built the same way
the GNU one already is.

Also checked the security question this raises for real: TarStreamReader
unpacks archives from untrusted sources (Pterodactyl backups, S3 objects),
the same trust boundary LayoutDetector was recently hardened against for
path traversal and symlink escapes. Archives.extractTar already resolves
and contains every entry path (resolveSafely), and neither it nor
TarStreamReader ever calls Files.createSymbolicLink, so a typeflag '2'
entry lands as an inert regular file rather than a real symlink -- no fix
was needed here. Added ArchivesTest as regression coverage for both: a
'../' entry is rejected before anything is written, and a symlink-typed
entry never becomes a real filesystem symlink.
…riter

BundleWriter.write() had no way to fill manifest.source.type or
manifest.minecraftVersion because task 3 (bundle writer) and task 4
(connectors) were built in parallel worktrees, neither aware of the
other's concrete values. Only the orchestrator knows both, so extend
write()'s signature to accept them and pass them straight into the
manifest.

Also make WorldLayout implement BundleWriter.WorldLayoutLike: task 2
and task 3 defined the same shape (kind()/dimensions()) independently
so neither had to wait on the other, but nothing ever declared the
record to actually satisfy the interface, which only surfaces once
something (the ingest orchestrator) tries to pass a WorldLayout to
BundleWriter directly.
IngestMain wires the pieces built in tasks 1-4 into a runnable
Kubernetes Job, the ingest-side analog of runner/: read and validate
every environment variable up front (fail with a clear message and a
non-zero exit before anything is fetched), pick the S3 or Pterodactyl
connector, fetch into a work directory, detect the layout, then write
the bundle. Progress goes to stdout as periodic, throttled lines plus
phase markers -- no HTTP server, since the job is short-lived and its
Kubernetes Job/Pod status already gives a reconciler the coarse state
it needs; see ingest/README.md's "Design notes" for the full
reasoning, including why the Minecraft version comes from
APUS_MC_VERSION rather than parsing level.dat.

Also split S3SourceConnectorTest into its own :ingest:integrationTest
task, matching the runner/operator convention: container-backed tests
must not be part of the routine build/check.
…ingest reconcilers

WorldSourceReconciler needs to evaluate WorldSource.spec.poll (a Cron expression)
and to call the ingest connectors' discover() directly; WorldIngestReconciler's
retention enforcement needs to list/delete objects in the bundle bucket. Adds
cron-utils 9.2.1 to the version catalog (verified against Maven Central; its only
non-test runtime dependency is slf4j-api) instead of hand-rolling a cron parser,
and wires the operator module to the ingest module and the AWS SDK S3 client.
Closes the loop from a configured WorldSource to a rendered map: WorldSourceReconciler
evaluates spec.poll on a Cron schedule (CronSchedule, backed by cron-utils), calls the
matching WorldSourceConnector.discover(), and creates one deterministically-named
WorldIngest per configured world when a new source version appears -- idempotent by
construction, so a retried reconcile never double-triggers an ingest. WorldIngestReconciler
builds the ingest Job via IngestJobBuilder, enforces that only one ingest per WorldSource
runs at a time via an optimistic lock on WorldSource.status.activeIngest (mirroring
BlueMapRenderReconciler's map-level lock), mirrors best-effort progress parsed from the
ingest pod's log lines (IngestLogProgress) into status, and on success writes
WorldSource.status.latestBundle before enforcing spec.retention.keepVersions -- never
deleting a bundle version still referenced by any BlueMapRender (AwsBundleStore /
BundleStore).

Both reconcilers follow the phase 2a patterns: name+UID ownership checks before adopting
an existing resource (WorldIngestReconciler on the Job, WorldSourceReconciler on a
colliding WorldIngest name), client.supports()-free since neither CRD is foreign, and the
shared Labels class (extended with SOURCE/SOURCE_UID) for every created resource.

WorldSourceStatus gains an ActiveIngest lock field (mirroring BlueMapMapStatus.latestRender)
and OperatorConfig gains the operator-wide ingest image and bundle-destination settings
(bundleBucket, bundleS3Endpoint, bundleS3Region, bundleCredentialsSecretName) neither CRD
carries a field for. Both reconcilers are registered in ApusOperator.
…tion tests

Widens the entry point's visibility from package-private to public so a
container-based integration test in another module can drive the real
fetch -> detect -> write orchestration instead of reimplementing it by
hand. Also corrects two stale forward-references (IngestMainTest javadoc,
ingest/README.md) that assumed this end-to-end test would live in
:ingest:integrationTest -- it lives in :runner:integrationTest instead,
next to the render machinery it also needs.
Adds IngestRenderContractTest to 🏃integrationTest: builds a
Bukkit-layout world (sibling world/world_nether/world_the_end folders)
from testdata/mini-world's real region files, runs the real
IngestMain.run entry point against a Testcontainers MinIO, then asserts
the resulting manifest is complete with the three normalised logical
dimension names and a region list matching what was actually written.
Cross-checks the manifest's claims against a real S3 object listing --
including that manifest.json's own lastModified is never older than any
region file's, proving it really is the bundle's last-written object
against a real backend, not just a fake client's call log.

Finally takes the bundle's own overworld dimension path, builds the same
bundleUrl shape a BlueMapRender carries, and starts the real
apus/runner:dev image (phase 1) against it, asserting a genuine rendered
tile lands in the map bucket -- the actual proof that the contract
between the ingest and render halves of phase 2b holds, not just that
each half works in isolation. Reuses MinioFixtures's MinIO/network/
runner-container helpers rather than duplicating them.

Depends on :ingest from :runner's test source set, the same "depend on
the module instead of duplicating its logic" pattern operator/
build.gradle.kts already uses.
…oi, record true source ref

BundleWriter wrote bundles under <tenant>/<worldId>/<version> with no
source-scoping segment, so two WorldSources ingesting a world with the same
id (e.g. the vanilla default "world") would silently share a bucket prefix;
a retention pass for one could delete the other's still-referenced bundle.
BundlePath is now the single place this path is built (previously
duplicated in BundleWriter, AwsBundleStore and WorldIngestReconciler), and
every caller includes the owning source's name.

Also: the writer only ever copied *.mca region files, dropping level.dat,
entities/ and poi/ even though the bundle spec lists all three as bundle
content; and manifest.source.ref held the bundle's own version identifier
instead of the source's, permanently losing where a bundle actually came
from (e.g. a Pterodactyl backup UUID). Both fixed in the same write() pass:
level.dat is read from the overworld dimension's region directory's parent,
entities/poi are included per-dimension where present, and the caller now
supplies the real source version id for source.ref.
…ve limits

APUS_BUNDLE_SOURCE_NAME is now a mandatory env var (see the bundle-path
commit) so IngestConfig always has it available for BundleWriter. Also adds
APUS_MAX_ARCHIVE_TOTAL_BYTES/APUS_MAX_ARCHIVE_ENTRIES, both optional with
generous defaults, threaded into each connector's source config map so
Archives can enforce them during extraction.
Archives.extractZip/extractTar wrote every entry with no cap on count,
per-entry size, or total volume. The ingest job mounts no volume and sets
no resource limit for its work directory, so extraction lands on the
container's writable layer backed by the node's own disk -- a crafted
"archive bomb" (or just an unexpectedly large legitimate archive) could
fill it and degrade every other pod scheduled on that node.

Both limits are now enforced against bytes/entries actually
produced during extraction (a counting OutputStream wrapper for zip and for
tar's transferTo), not a declared/expected size -- entry metadata is
attacker-controlled input and, for zip in particular, not always even
present. Exceeding either limit aborts extraction with a clear IOException.
Limits default to generous values (5 GiB / 200k entries) and are
configurable via IngestConfig (previous commit); the existing unbounded
overloads are kept so callers that intentionally want no cap (tests) are
unaffected.

S3SourceConnector.fetch now passes Archives.limitsFrom(config) through to
extraction; PterodactylConnector's wiring is in the next commit alongside
its other hardening.
…ion, no URL leakage

Several independent problems in the same connector:

- No connect or request timeout on its HttpClient, so a panel that accepts
  a connection and never responds could hang the calling thread forever.
  discover() runs directly inside WorldSourceReconciler, whose JOSDK worker
  pool is shared across all five reconcilers, so a hung panel would starve
  unrelated render/ingest reconciliation too. Both timeouts are now set
  (30s), including on the archive download request itself -- that timeout
  only bounds receiving the response, not the full multi-GB body transfer.

- A failure streaming the backup archive embedded the signed download URL
  directly in the exception message, which lands on stderr and therefore in
  log aggregation. The signed URL is a short-lived but fully-privileged
  credential for the entire backup; the message now names only the host.

- discover() assumed the backups-list response always has the
  {"object":"list","data":[...]} envelope without checking. A response that
  doesn't match (different API version, a proxy error page returned with a
  2xx, ...) made root.path("data") resolve to an empty Jackson node, which
  iterates as zero backups -- reported as a perfectly healthy "no versions
  available yet" instead of a visible failure. Now validated explicitly and
  rejected with a clear error if the envelope doesn't match.

- fetch() now also passes Archives.limitsFrom(config) through to
  extractTar, completing the archive-extraction bound from the previous
  commit for this connector too.
BundleStore.listVersions/deleteVersion took only tenant+worldId, matching
the pre-fix bundle path shape. Now takes sourceName too and uses the shared
BundlePath (ingest module) to build prefixes, keeping AwsBundleStore's
listing/deletion scoped to one source's own bundles -- see the bundle-path
commit for why worldId alone was never enough to keep two sources from
colliding.
…urce ownership

Four related correctness bugs in WorldIngestReconciler, found together in
the same review pass:

- applyProgress() copied the ingest pod's log phase verbatim into
  status.phase, including terminal values. IngestMain logs
  "phase=Succeeded" immediately before its process exits -- strictly before
  the Job controller can have observed the pod's exit and set
  status.succeeded. A reconcile landing in that window would adopt
  Succeeded from the log, and reconcile() treats any already-terminal
  ingest as a permanent no-op on every future call: onJobSucceeded (which
  fills WorldSource.status.latestBundle and WorldIngest.status.bundle) then
  never runs, even once the Job genuinely succeeds. Terminality now belongs
  exclusively to isJobSucceeded/isJobFailed, evaluated against the Job's
  own status; applyProgress only ever advances to a non-terminal phase.

- isJobFailed() also treated status.failed > 0 (any failed pod attempt) as
  the whole Job having failed, even though backoffLimit exists precisely so
  a transient failure gets retried. The first failed attempt would end the
  ingest terminally while the Job kept running underneath it and could
  still write a complete bundle on a later attempt -- which then has
  nowhere to be registered, since a terminal ingest is never reconciled
  again. Now only the Job's own Failed condition (set once backoffLimit is
  exhausted) counts.

- The reconciler resolved WorldSource purely by spec.sourceRef.name and
  then wrote its status and drove retention (including deletions) against
  it, without checking the labels WorldSourceReconciler stamps on every
  WorldIngest it creates. A hand-written or stale WorldIngest -- e.g. its
  original source was deleted and a different source created under the
  same name, giving it a different UID -- could control a source it never
  triggered. Applies the same name+UID owner-check pattern already used
  elsewhere in this module: a mismatch reports ResourceConflict instead of
  proceeding.

- applyRetention now also protects every WorldSource's recorded
  status.latestBundle in the namespace, not just the source the current
  pass belongs to -- a safety net alongside (not instead of) the
  source-scoped bundle path from the BundlePath commit.

New/updated tests in WorldIngestReconcilerTest cover each of the four
(logReportingSucceededBeforeTheJobStatusDoesNotEndTheIngestPrematurely,
aSingleFailedPodAttemptDoesNotEndTheIngestWhileTheJobStillHasRetriesLeft,
anIngestWithoutTheSourceOwnerLabelsIsReportedAsAConflictAndNeverTouchesTheSource,
twoSourcesWithTheSameWorldNameNeverCollideOnTheSameBundlePath,
retentionNeverDeletesAVersionRecordedAsAnotherSourcesLatestBundle); existing
tests updated for the new source-scoped bundle path and the Job Failed
condition.
…inally failed

Same mistake as WorldIngestReconciler's isJobFailed (previous commit):
BlueMapRenderReconciler counted status.failed > 0 (any failed pod attempt)
as the whole Job having failed, even though backoffLimit exists so a
transient failure gets retried. Now only the Job's Failed condition counts.

New test: aSingleFailedPodAttemptDoesNotEndTheRenderWhileTheJobStillHasRetriesLeft.
…ephemeral storage

manifest.minecraftVersion was a mandatory bundle-spec field the operator
never filled: IngestJobBuilder never set APUS_MC_VERSION, so it stayed
permanently empty. Reading it from level.dat was considered and rejected --
this project deliberately carries no NBT-parsing dependency, and level.dat
is genuinely absent for connectors that only fetch specific region files.
Instead WorldSource.spec.worlds[].minecraftVersion is a new optional field
the tenant sets directly (they already know which version they run);
IngestJobBuilder reads it for the matching world selector and passes it
through as APUS_MC_VERSION, same as it already does for layout.

Also sets APUS_BUNDLE_SOURCE_NAME (see the bundle-path commit) and gives
the ingest container an ephemeral-storage request/limit: it mounts no
volume for its work directory or the archive it extracts, so even a
legitimate large world could otherwise starve the node's disk. This is
defense in depth alongside (not instead of) Archives' own configurable
extraction limits.
Updates the environment-variable contract table and the "Minecraft
version" design note for APUS_BUNDLE_SOURCE_NAME (now mandatory) and the
new APUS_MAX_ARCHIVE_TOTAL_BYTES/APUS_MAX_ARCHIVE_ENTRIES variables added
in prior commits.
@gitguardian

gitguardian Bot commented Aug 8, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 2 secrets following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secrets in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
35899484 Triggered Generic High Entropy Secret c1d2ef4 ingest/src/main/java/net/onelitefeather/apus/ingest/IngestConfig.java View secret
35899483 Triggered Generic High Entropy Secret 1ab1a89 operator/src/main/java/net/onelitefeather/apus/operator/ingest/IngestJobBuilder.java View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secrets safely. Learn here the best practices.
  3. Revoke and rotate these secrets.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

…rdcoding them

Same fix as feat/phase-1-render-kern's MinioFixtures: S3SourceConnectorTest
hardcoded the well-known MinIO default access/secret key pair for its
throwaway Testcontainers instance. 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 #11: 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 #11 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