Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
187e49d
docs: plan the remaining work as phases 7 to 9
TheMeinerLP Aug 13, 2026
8e71ffe
docs: add a root README with module overview and build instructions
TheMeinerLP Aug 13, 2026
66fb3ea
ci: adopt the central OneLiteFeather Renovate preset
TheMeinerLP Aug 13, 2026
2d44ae3
chore: manage versions and changelogs with release-please
TheMeinerLP Aug 13, 2026
b17bbcc
ci: build and test Gradle modules and the UI on pull requests
TheMeinerLP Aug 13, 2026
495fc3c
ci: widen build-pr code filter and pin ui Node version to .nvmrc
TheMeinerLP Aug 13, 2026
8f383c3
ci: lint markdown and close pull requests from fork default branches
TheMeinerLP Aug 13, 2026
8f0b92e
feat: package the operator as a container image
TheMeinerLP Aug 13, 2026
8c4c703
feat: package the API as a container image
TheMeinerLP Aug 13, 2026
d560ee5
feat: package the dashboard as a static nginx container image
TheMeinerLP Aug 13, 2026
03996ab
ci: publish all six container images on release
TheMeinerLP Aug 13, 2026
8ef7d8f
feat: publish telemetry-addon and paper-worldpush to the OneLiteFeath…
TheMeinerLP Aug 13, 2026
0f7ac6c
docs: record the phase 7 delivery state in the design spec
TheMeinerLP Aug 13, 2026
e455c8d
fix(runner): fetch the S3 storage addon in the image build
TheMeinerLP Aug 13, 2026
0614747
fix(release-please): resolve extra-files per package and pin the full…
TheMeinerLP Aug 13, 2026
3f73a8a
ci: serialise the image publishes and slim the docker build context
TheMeinerLP Aug 13, 2026
1851343
ci: repair the pull-request and markdown-lint workflows
TheMeinerLP Aug 13, 2026
af528b5
chore: re-enable MD041 and correct the shadow-jar rationale
TheMeinerLP Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Applies to every image built from this repo root (build context is always `.`).
#
# ui/ (Nuxt) keeps build output and dependencies on disk even though they are gitignored (see
# .gitignore). Without this file, `COPY ui/ ./` in ui/Dockerfile would copy the host's
# node_modules/.output/.nuxt over the ones the build stage just produced -- pnpm aborts rather
# than silently overwrite a foreign node_modules, and even if it didn't, stale local build
# output could end up shipped in the image.
ui/node_modules/
ui/.nuxt/
ui/.output/
ui/dist/
ui/coverage/

.git/
55 changes: 55 additions & 0 deletions .github/workflows/build-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: build-pr

on:
pull_request:
branches: [main]

jobs:
gradle:
uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-build-pr.yml@v2.4.0
with:
java-version: "25"
java-distribution: "temurin"
# The central default is a three-OS matrix. Apus only ever ships to Linux
# containers, so windows/macos runners would burn minutes on a platform nothing
# deploys to.
runs-on: '["ubuntu-latest"]'
paths-filters: |
code:
- '**/*.java'
- '**/*.kts'
- '**/*.properties'
- 'gradle/**'
- 'gradlew'
- 'gradlew.bat'
- '.spotless/**'
- '**/src/**/resources/**'
- '**/entrypoint.sh'
- '**/bin/*.sh'
# Kept from the central default: a CI-only change must still run the build,
# otherwise a workflow edit merges without ever having been exercised.
- '.github/workflows/**'
secrets: inherit

ui:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ui
steps:
- uses: actions/checkout@v5
# There is no root package.json; packageManager lives in ui/package.json, and
# `defaults.run.working-directory` does not apply to action inputs, so the action
# has to be told where to read the pnpm version from.
- uses: pnpm/action-setup@v4
with:
package_json_file: ui/package.json
- uses: actions/setup-node@v5
with:
node-version-file: ui/.nvmrc
cache: pnpm
cache-dependency-path: ui/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test
10 changes: 10 additions & 0 deletions .github/workflows/close-invalid-prs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
name: close-invalid-prs

on:
pull_request_target:
types: [opened]

jobs:
close:
uses: OneLiteFeatherNET/workflows/.github/workflows/close-invalid-prs.yml@v2.4.0
secrets: inherit
16 changes: 16 additions & 0 deletions .github/workflows/markdown-lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: markdown-lint

on:
pull_request:
branches: [main]
paths:
- '**/*.md'

jobs:
lint:
uses: OneLiteFeatherNET/workflows/.github/workflows/markdown-lint.yml@v2.4.0
with:
# The central default is `.markdownlint.json`, which this repo does not have --
# markdownlint-cli2 then throws and the job is always red.
config-file: .markdownlint-cli2.jsonc
secrets: inherit
176 changes: 176 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
name: release-please

on:
push:
branches: [main]

permissions:
contents: write
pull-requests: write

jobs:
release-please:
runs-on: ubuntu-latest
outputs:
root-released: ${{ steps.release.outputs['.--release_created'] }}
root-version: ${{ steps.release.outputs['.--version'] }}
telemetry-released: ${{ steps.release.outputs['telemetry-addon--release_created'] }}
paper-released: ${{ steps.release.outputs['paper-worldpush--release_created'] }}
steps:
- id: release
uses: googleapis/release-please-action@v5
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json

build-context:
needs: release-please
if: needs.release-please.outputs.root-released == 'true'
uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-docker-context.yml@v2.4.0
with:
java-version: "25"
version: ${{ needs.release-please.outputs.root-version }}
gradle-command: "./gradlew :telemetry-addon:shadowJar :ingest:shadowJar :operator:shadowJar :api:shadowJar"
# Only the four shadow jars are needed by the Dockerfiles. Uploading "." would ship
# the whole working tree (including .git and every build/ directory -- hundreds of
# MB) because the central workflow passes context-path straight to
# actions/upload-artifact with include-hidden-files: true.
# upload-artifact roots the archive at the least common ancestor of the matched
# files; the four jars live in four different top-level modules, so that ancestor is
# the workspace root and the artifact keeps the `<module>/build/libs/` prefix the
# Dockerfiles COPY by name.
context-path: "**/build/libs/*.jar"
artifact-name: "docker-context"
secrets: inherit

# The six publish-* jobs below are chained with `needs` on purpose -- do NOT "optimise"
# them back into parallel jobs. docker-publish.yml@v2.4.0 declares
# concurrency: group: docker-publish-${{ github.workflow }}-${{ github.ref }}
# which every one of these six calls evaluates to the identical string, and a caller
# cannot override a callee's concurrency. Running them in parallel therefore puts all six
# in one concurrency group where they cancel each other. Serialising them keeps at most
# one of them pending at any time. (The proper fix is a `concurrency-suffix` input on the
# central docker-publish.yml; that is an upstream change.)
publish-runner:
needs: [release-please, build-context]
# First link of the chain. The three jobs after it gate on build-context's own result
# rather than on their predecessor, so a single failing image does not silently skip
# the rest -- the chain exists for serialisation only, not to express a dependency.
if: ${{ !cancelled() && needs.build-context.result == 'success' }}
permissions:
contents: read
id-token: write
uses: OneLiteFeatherNET/workflows/.github/workflows/docker-publish.yml@v2.4.0
with:
image-name: "apus/runner"
version: ${{ needs.release-please.outputs.root-version }}
context: "."
dockerfile: "runner/Dockerfile"
artifact-name: "docker-context"
secrets: inherit

publish-ingest:
needs: [release-please, build-context, publish-runner]
if: ${{ !cancelled() && needs.build-context.result == 'success' }}
permissions:
contents: read
id-token: write
uses: OneLiteFeatherNET/workflows/.github/workflows/docker-publish.yml@v2.4.0
with:
image-name: "apus/ingest"
version: ${{ needs.release-please.outputs.root-version }}
context: "."
dockerfile: "ingest/Dockerfile"
artifact-name: "docker-context"
secrets: inherit

publish-hosting:
# publish-api is a chain link only (see the concurrency note above); hosting builds
# from the checked-out repo and needs no Gradle artifact, so `!cancelled()` keeps its
# gate standalone -- it publishes whenever a root release happened, even if one of the
# Gradle-based images failed.
needs: [release-please, publish-api]
if: ${{ !cancelled() && needs.release-please.outputs.root-released == 'true' }}
permissions:
contents: read
id-token: write
uses: OneLiteFeatherNET/workflows/.github/workflows/docker-publish.yml@v2.4.0
with:
image-name: "apus/hosting"
version: ${{ needs.release-please.outputs.root-version }}
context: "hosting"
dockerfile: "hosting/Dockerfile"
secrets: inherit

publish-operator:
needs: [release-please, build-context, publish-ingest]
if: ${{ !cancelled() && needs.build-context.result == 'success' }}
permissions:
contents: read
id-token: write
uses: OneLiteFeatherNET/workflows/.github/workflows/docker-publish.yml@v2.4.0
with:
image-name: "apus/operator"
version: ${{ needs.release-please.outputs.root-version }}
context: "."
dockerfile: "operator/Dockerfile"
artifact-name: "docker-context"
secrets: inherit

publish-api:
needs: [release-please, build-context, publish-operator]
if: ${{ !cancelled() && needs.build-context.result == 'success' }}
permissions:
contents: read
id-token: write
uses: OneLiteFeatherNET/workflows/.github/workflows/docker-publish.yml@v2.4.0
with:
image-name: "apus/api"
version: ${{ needs.release-please.outputs.root-version }}
context: "."
dockerfile: "api/Dockerfile"
artifact-name: "docker-context"
secrets: inherit

publish-ui:
# Last chain link; same standalone gate as publish-hosting.
needs: [release-please, publish-hosting]
if: ${{ !cancelled() && needs.release-please.outputs.root-released == 'true' }}
permissions:
contents: read
id-token: write
uses: OneLiteFeatherNET/workflows/.github/workflows/docker-publish.yml@v2.4.0
with:
image-name: "apus/ui"
version: ${{ needs.release-please.outputs.root-version }}
context: "."
dockerfile: "ui/Dockerfile"
secrets: inherit

publish-telemetry-addon:
needs: release-please
if: needs.release-please.outputs.telemetry-released == 'true'
# Narrow the workflow-level `contents: write, pull-requests: write` down to what a
# Maven publish actually needs, matching the six Docker jobs.
permissions:
contents: read
uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-publish.yml@v2.4.0
with:
java-version: "25"
java-distribution: "temurin"
build-task: ":telemetry-addon:build"
publish-task: ":telemetry-addon:publish"
secrets: inherit

publish-paper-worldpush:
needs: release-please
if: needs.release-please.outputs.paper-released == 'true'
permissions:
contents: read
uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-publish.yml@v2.4.0
with:
java-version: "25"
java-distribution: "temurin"
build-task: ":paper-worldpush:build"
publish-task: ":paper-worldpush:publish"
secrets: inherit
14 changes: 14 additions & 0 deletions .markdownlint-cli2.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"config": {
// The design spec and the plans use long prose lines; wrapping them would make
// diffs unreadable.
"MD013": false,
// Release Please writes the changelog; its heading structure is not ours to police.
"MD024": { "siblings_only": true }
},
"ignores": [
"**/node_modules/**",
"**/build/**",
"CHANGELOG.md"
]
}
5 changes: 5 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
".": "0.1.0",
"telemetry-addon": "0.1.0",
"paper-worldpush": "0.1.0"
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ WorldIngest, BlueMapHosting) — confirms `Certificate` was not picked up by the
## File restriction compliance

Only these files were created/modified:

- `operator/src/main/java/net/onelitefeather/apus/operator/hosting/HostingResourceBuilder.java` (new)
- `operator/src/main/java/net/onelitefeather/apus/operator/hosting/Certificate.java` (new)
- `operator/src/test/java/net/onelitefeather/apus/operator/hosting/HostingResourceBuilderTest.java` (new)
Expand Down
8 changes: 4 additions & 4 deletions .superpowers/sdd/2026-08-09-phase-6-push/final-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ already resolves a token to a *namespace*, not a source, which only makes sense
reading.

- New `PushTokenSecrets` (operator, package `tenant`) is the single canonical definition of the
Secret shape (label, data key, fixed name `apus-push-token`, `generate()` using `SecureRandom`
+ URL-safe base64, 256 bits).
Secret shape (label, data key, fixed name `apus-push-token`, `generate()` using `SecureRandom` +
URL-safe base64, 256 bits).
- `TenantReconciler` creates this Secret once per tenant, alongside the namespace. Critically, it
is **never regenerated** on later reconciles (no `createOr(update)` here) — a fresh random
value on every resync would silently invalidate whatever `paper-worldpush` was already
Expand All @@ -47,8 +47,8 @@ exists anywhere in this repo to hang it on): `FabricPushTokenRepository`'s Javad
that its current `list()`-by-label lookup, unavoidably, needs `get`/`list` on **all** Secrets
cluster-wide (Kubernetes RBAC cannot filter by label) — broader than ideal — and documents the
concrete narrower alternative (enumerate tenants via the already-listable `Tenant` CR, then `get`
the fixed-name Secret per namespace, letting RBAC restrict to `resourceNames: ["apus-push-token"]`
+ `get` only) as a deliberate follow-up, not implemented now to avoid an invasive rewrite of
the fixed-name Secret per namespace, letting RBAC restrict to `resourceNames: ["apus-push-token"]` +
`get` only) as a deliberate follow-up, not implemented now to avoid an invasive rewrite of
already-tested code under this task's scope. Flagged as a concern below and as open item 9 in the
spec.

Expand Down
2 changes: 1 addition & 1 deletion .superpowers/sdd/2026-08-09-phase-6-push/task-2-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ The one endpoint in the module that is **not** JWT-authenticated
## Which upload restrictions are actually enforced — the honest answer

| Restriction | Status | How it was verified |
|---|---|---|
| --- | --- | --- |
| **Confined to the caller's own tenant prefix** | **Enforced, structurally.** | `stagingKey` is a pure function of a server-derived namespace; unit-tested with adversarial input. S3 has no `..`-traversal semantics, so there is no string a caller can supply that escapes the prefix. |
| **A presigned part URL can't be redirected to a different key** | **Enforced, confirmed against real MinIO.** | `MultipartUploadServiceIntegrationTest.aPresignedPartUrlCannotBeRedirectedToADifferentTenantsKey` swaps the tenant segment in a legitimate presigned URL and gets HTTP 403 from MinIO — SigV4 signs the exact key. |
| **A part can't carry more bytes than it was sized for** | **Enforced, confirmed against real MinIO (2026-08-09).** | `Content-Length` is set on each presigned `UploadPartRequest`; AWS SDK v2 includes it among that URL's signed headers. Sending more bytes than declared gets HTTP 403 `SignatureDoesNotMatch` from MinIO before the extra bytes are accepted — I drove a real oversized `PUT` against a real MinIO instance rather than trusting SDK documentation (which does not state this explicitly). **Caveat**: verified against MinIO specifically, not independently re-verified against Ceph RGW (the actual production backend per design spec §9.1). Both implement SigV4 presigned-URL validation the same way, so I expect the same result, but that is an inference from one data point, not a second measurement. |
Expand Down
Empty file added CHANGELOG.md
Empty file.
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Apus

Apus rendert Minecraft-Welten mit [BlueMap](https://bluemap.bluecolored.de/) auf Kubernetes
und hostet die Ergebnisse. Welt-Daten kommen aus mehreren, sehr unterschiedlichen Quellen;
ein ETL-Layer normalisiert sie, ein Operator führt Render- und Hosting-Jobs aus, eine
Oberfläche zeigt Fortschritt und erlaubt Bedienung ohne YAML.

Das vollständige Design steht in
[`docs/superpowers/specs/2026-08-08-apus-design.md`](docs/superpowers/specs/2026-08-08-apus-design.md).

## Module

| Modul | Zweck | Auslieferung |
| --- | --- | --- |
| `telemetry-addon` | BlueMap-Addon, exponiert Render-Fortschritt als JSON und Prometheus-Metriken | Maven |
| `ingest` | ETL: Connectoren (s3, pterodactyl, push, upload), Layout-Erkennung, Bundle-Writer | Container-Image |
| `runner` | BlueMap-CLI plus beide Addons, rendert eine Welt aus S3 nach S3 | Container-Image |
| `hosting` | Langlebiger BlueMap-Webserver, liest gerenderte Karten aus S3 | Container-Image |
| `operator` | Kubernetes-Operator, sechs CRDs, erzeugt Jobs/Deployments/Ingresses/Buckets | Container-Image |
| `api` | Micronaut-REST/SSE über den Custom Resources, Durchsetzungspunkt für Auth | Container-Image |
| `ui` | Nuxt-4-Dashboard für Mandanten und Plattform-Betreiber | Container-Image |
| `paper-worldpush` | Paper-Plugin, schiebt Welten vom laufenden Server nach Apus | Maven |

## Bauen

Voraussetzungen: JDK 25, Docker (für Integrationstests), pnpm (für `ui`).

./gradlew build # alle Java-Module, ohne Integrationstests
./gradlew integrationTest # braucht Docker
./gradlew :operator:generateCrds # erzeugt die sechs CRD-YAMLs nach operator/build/crds

cd ui && pnpm install && pnpm test && pnpm lint

## Entwicklung

Der Kern des Systems ist das **World Bundle** — eine unveränderliche, normalisierte
Momentaufnahme einer Welt in S3. Links davon (Ingest) weiß niemand etwas von BlueMap,
rechts davon (Render, Hosting) niemand etwas von Pterodactyl oder ZIP-Uploads. Wer eine
neue Welt-Quelle anbindet, implementiert nur `WorldSourceConnector` in `ingest`.

Commits folgen [Conventional Commits](https://www.conventionalcommits.org/) — Release
Please leitet daraus Version und Changelog ab.

## Lizenz

AGPL-3.0, siehe [LICENSE](LICENSE).
15 changes: 15 additions & 0 deletions api/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# syntax=docker/dockerfile:1

FROM eclipse-temurin:25-jre-jammy

RUN useradd --uid 10001 --create-home --home-dir /home/apus apus

# Built by: ./gradlew :api:shadowJar
COPY --chown=apus:apus api/build/libs/apus-api.jar /opt/apus/api.jar

USER apus
WORKDIR /home/apus

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "/opt/apus/api.jar"]
Loading
Loading