Skip to content

feat: Phase 3 — hosting rendered maps under their own address - #4

Closed
TheMeinerLP wants to merge 12 commits into
feat/phase-2b-ingestfrom
feat/phase-3-hosting
Closed

feat: Phase 3 — hosting rendered maps under their own address#4
TheMeinerLP wants to merge 12 commits into
feat/phase-2b-ingestfrom
feat/phase-3-hosting

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

Phase 3 of Apus: hosting. Stacked on PR #3 (phase 2b), which must land first.

A rendered map now becomes reachable under its own address. A BlueMapHosting resource
produces a BlueMap webserver Deployment that reads the finished maps straight from S3,
plus Service, Ingress and — where cert-manager is present — a Certificate, and reports
the URL back in status.

What this delivers

Component Role
BlueMapHosting CRD maps to publish, hostname, ingress class, TLS, replicas
BlueMapConfigBuilder.buildForHosting multi-map config plus webserver.conf — the case the env-var contract cannot cover
hosting image BlueMap CLI in webserver mode with the S3 storage addon
HostingResourceBuilder Deployment, Service, Ingress, Certificate — all owned by the hosting resource
BlueMapHostingReconciler wires it together, enforces tenant isolation, restarts pods on config change

BlueMapConfigBuilder was built in phase 2a, found to be pointless for the render path,
and deliberately kept unwired with a javadoc note saying phase 3 would need it. That is
exactly what happened.

Two multi-tenancy holes, closed here

Both were reported by a security review of the merged builder code.

Hostnames were never checked against the tenant's allowed domains. Tenant.spec.hosting.allowedDomains
was documented in the spec but modelled nowhere and enforced by nobody — so a tenant could
have claimed another tenant's hostname and taken over its traffic. The reconciler now
resolves the owning tenant via the namespace label, matches the hostname (literal or
single-level wildcard, mirroring wildcard-TLS semantics) and refuses with a condition
instead of creating an Ingress. An empty domain list means "not configured", not
"unrestricted".

Referenced maps were not confined to the hosting's own namespace. A hosting could have
published another tenant's maps. Maps are now resolved only within the hosting's namespace;
a missing one is an error, not a hint to look elsewhere.

Bugs the real cluster found that mocks could not

  • ConfigMap data keys may not contain /. The generated config used logical keys like
    maps/survival-overworld.conf; the mock server accepted them, a real API server rejects
    them outright. Keys are now sanitised, with the nested path restored through the volume's
    items[].path, where slashes are legal. Without the cluster test this would have broken
    every hosting in production.
  • Certificate.CertificateStatus was a genuinely empty class, which crashes Jackson on
    any real client write. Invisible while only the pure builder was under test.
  • -w alone serves 404 for everything until -g has run once — found by actually
    running the image, not by reading flags.

Testing

204 operator unit tests, plus 8 integration tests against real k3s. The
client.supports(Certificate.class) branch is now genuinely exercised: on a cert-manager-less
cluster it returns false and the reconciler blocks the whole hosting rather than silently
skipping TLS. That branch was previously untestable — the mock server always answers true.

Explicitly not covered

  • No real Ingress controller, so no HTTP request travelling through one. Task 2 did prove a
    hosting pod serves real tiles over HTTP via a direct call.
  • The cert-manager-present path (an actual Certificate being issued) is still mock-only.
  • Rook stays faked via BlueMapMap.status.bucket, as in the existing operator integration test.
  • No separate whole-phase review ran; the findings above came from the per-task reviews and
    the background security review.

…uilder

Ports Phase 3's hosting surface onto the api/ pattern established by
BlueMapMap and WorldSource: BlueMapHosting is a namespaced custom resource
fronting several already-rendered maps behind one hostname/ingress.

Extends BlueMapConfigBuilder (deliberately unwired since Phase 2a) with
buildForHosting(), which emits a webserver.conf plus one maps/<id>.conf and
one storages/<id>.conf per map, since different maps can live in different
buckets. Credentials stay out of every generated file, matching the
render-case contract.

Verified webserver.conf's real format against BlueMap 5.23 (apus/runner:dev's
bundled CLI, run with no action flag against an empty config folder): it has
no bind-address/ip key at all, so no such key is emitted -- the file instead
documents in a comment that the webserver listens on all interfaces
unconditionally.
Turns a BlueMapHosting into the Deployment, Service, Ingress and
(when TLS is enabled) cert-manager Certificate that make an
already-rendered map reachable on the web. Pure builder, no
Kubernetes client, following RenderJobBuilder's pattern: owner
references on every resource, shared Labels, S3 credentials via
secretKeyRef, and readiness/liveness probes so the Service never
routes to a pod still loading its maps from S3.

Adds a lean client-side Certificate model for cert-manager.io/v1,
kept outside the operator.api package so the CRD generator's
package filter never picks it up.
Task reports are working material, not project documentation — every other
phase keeps them under the gitignored .superpowers/ directory. This one
landed in docs/ because the agent's worktree did not carry that directory.
Runs the same BlueMap CLI as runner/, in webserver mode (-gw) instead of
render mode. Map/storage config comes from a mounted, read-only ConfigMap
(as produced by BlueMapConfigBuilder.buildForHosting) rather than env vars,
since a multi-map webserver config doesn't fit that contract. The entrypoint
copies the read-only config into a writable directory and appends S3
credentials to each storages/*.conf file there, via a file rather than argv,
so they never appear in the process command line.

Verified against a real MinIO seeded by runner/ with a rendered map:
GET /settings.json (200, lists configured maps) is the readiness path for
Task 4's probes; GET /maps/<id>/tiles/... serves real rendered tile data
read live from S3.
…olation checks

Reconciles a BlueMapHosting into a ConfigMap (BlueMapConfigBuilder.buildForHosting),
Deployment, Service, Ingress, and optional cert-manager Certificate, and registers it
in ApusOperator.

Closes two multi-tenancy findings from the prior review: the hostname is now checked
against the owning Tenant's spec.hosting.allowedDomains (new TenantSpec field, literal
or single-level wildcard match; an empty list means no hosting rather than unrestricted
hosting), and referenced BlueMapMaps are resolved only inside the hosting's own
namespace, refusing with a condition instead of a Deployment when either check fails or
a referenced map has no bound bucket yet.

Also: wires OperatorConfig.hostingImage (APUS_HOSTING_IMAGE) through
HostingResourceBuilder.deployment(), corrects PROBE_PATH to /settings.json per the task 2
image's verified behaviour, stamps a config-checksum annotation on the pod template so a
changed map list restarts the webserver pods, and fixes Certificate.CertificateStatus
(an empty class Jackson's FAIL_ON_EMPTY_BEANS rejected on an actual client write, only
reachable once a real reconciler -- rather than the pure HostingResourceBuilder -- sent
one to a client).
Adds BlueMapHostingIntegrationTest, running against k3s via Testcontainers
alongside the existing OperatorIntegrationTest, wired into the operator
module's dedicated integrationTest Gradle task (not part of build/check).
It proves the generated bluemaphostings CRD registers on a real API server,
that a full reconcile produces a ConfigMap/Deployment/Service/Ingress with
the expected properties, that both the hostname-vs-tenant-allowedDomains and
map-must-exist-in-namespace checks still hold when resolved against a real
Tenant/namespace-label lookup, and that client.supports(Certificate.class)
genuinely returns false without cert-manager installed -- a branch the
Fabric8 mock server can never exercise since it always answers true.

Along the way this surfaced a real bug the mock server could not catch:
BlueMapConfigBuilder.buildForHosting's logical file paths (e.g.
"maps/survival-overworld.conf") were written directly as ConfigMap data
keys, which a real API server rejects outright since a data key may not
contain '/'. Fixed by sanitising each key (HostingResourceBuilder#configMapKey)
and restoring the original nested path via the ConfigMap volume's per-item
`path`, which -- unlike the data key -- Kubernetes does allow to contain '/'.

Also factors the CRD apply/await helpers used by both real-cluster test
classes into a shared net.onelitefeather.apus.operator.testsupport.K3sCrdSupport
instead of duplicating them.
@gitguardian

gitguardian Bot commented Aug 9, 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
35899483 Triggered Generic High Entropy Secret 411fe30 operator/src/test/java/net/onelitefeather/apus/operator/hosting/HostingResourceBuilderTest.java View secret
35899484 Triggered Generic High Entropy Secret 4fe542b hosting/entrypoint.sh 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.

…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 #12: 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 #12 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