Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions .gitguardian.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,20 @@ secret:
match: bfd5d64da90af877034e91f582242391fea586e6d187a475a3407bf37bd6f422
- name: "phase 1 MinIO test secret key (throwaway container, cause fixed)"
match: 9c721c67b04a5ff4622f5796d44a042c328d5453795ac798e14c7a7a31125bdd
# The two environment-variable NAMES that carry S3 credentials into the render,
# ingest and hosting containers. GitGuardian's "Generic High Entropy Secret"
# detector reads the identifiers themselves as secrets wherever they appear as
# string literals -- in Kubernetes EnvVar names and in the Secret data keys the
# operator wires up. They are names, not values: no credential is stored here.
#
# They cannot be removed. An EnvVar name and a Secret key have to exist as literal
# strings somewhere for the contract between the operator and the container images
# to work; moving them into constants only relocates the literal.
#
# Confirmed as one finding each across three files (RenderJobBuilder, IngestConfig,
# HostingResourceBuilder), which is why the scanner reports the same two incident
# ids for all of them -- it deduplicates by value.
- name: "APUS_S3_ACCESS_KEY -- env var name, not a credential"
match: 512400f5e1ec04e47002a185359c58e8f9365f44ef761803e1ea8525ab67ea5e
- name: "APUS_S3_SECRET_KEY -- env var name, not a credential"
match: 8e868512d55e22f28dcca4dc2a72c37148cd1e9f3e37071f6418241a5c1cb0a7
117 changes: 117 additions & 0 deletions api/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import java.time.Duration

// Needed before the integrationTest task below can reference :operator's generateCrds task by
// name -- without this, Gradle may configure :api before :operator has registered it.
evaluationDependsOn(":operator")

plugins {
application
}

dependencies {
// Tenant/BlueMapMap/BlueMapRender/WorldSource/WorldIngest/BlueMapHosting: pure CR data
// holders from phases 2a/2b/3, reused instead of duplicating their shape here.
implementation(project(":operator"))

// The fabric8 client itself -- see settings.gradle.kts for why this is needed explicitly
// even though :operator already depends on it (transitively, via `implementation`, which
// does not leak onto this module's compile classpath).
implementation(libs.fabric8.kubernetes.client)
runtimeOnly(libs.fabric8.httpclient.jdk)

implementation(platform(libs.micronaut.core.bom))
implementation(libs.micronaut.http.server.netty)
implementation(libs.micronaut.runtime)
annotationProcessor(platform(libs.micronaut.core.bom))
annotationProcessor(libs.micronaut.inject.java)

// JWT validation against a configurable issuer -- see settings.gradle.kts and
// src/main/resources/application.yml. Which identity broker sits in front of Apus is an
// open question (design spec §15); micronaut-security-jwt only needs an issuer and a JWKS
// endpoint, both of which are plain OIDC-discovery concepts every candidate broker exposes.
implementation(platform(libs.micronaut.security.bom))
implementation(libs.micronaut.security.jwt)
annotationProcessor(platform(libs.micronaut.security.bom))
annotationProcessor(libs.micronaut.security.annotations)

// JSON (de)serialisation for the REST responses task 2 adds.
implementation(platform(libs.micronaut.serde.bom))
implementation(libs.micronaut.serde.jackson)
annotationProcessor(platform(libs.micronaut.serde.bom))
annotationProcessor(libs.micronaut.serde.processor)

testImplementation(platform(libs.junit.bom))
testImplementation(libs.junit.jupiter)
testRuntimeOnly(libs.junit.platform.launcher)

// Test-only: TenantResolverTest proves its namespace convention ("bluemap-<tenant>") never
// drifts from TenantReconciler's, by calling the reconciler's own namespaceFor(Tenant)
// instead of duplicating the literal prefix as a second source of truth. JOSDK is not a
// main-code dependency of this module -- the api module never reconciles anything -- so it
// is scoped to testImplementation only, not the dependency added above for production code.
testImplementation(libs.josdk)

// Phase 5a consolidation: both parallel worktrees reported this as missing, which meant
// every existing test called controller/repository methods directly instead of going
// through the real embedded server -- so role enforcement and 404-vs-403 error mapping over
// the actual HTTP/security-filter path were never proven. `micronaut-test-junit5` provides
// `@MicronautTest`/`TestPropertyProvider`; `micronaut-http-client` backs the `@Client("/")
// HttpClient` it injects. Both test-only: production code never makes outbound HTTP calls.
testImplementation(platform(libs.micronaut.test.bom))
testImplementation(libs.micronaut.test.junit5)
testImplementation(libs.micronaut.http.client)
testAnnotationProcessor(platform(libs.micronaut.core.bom))
testAnnotationProcessor(libs.micronaut.inject.java)

// Test-only, for TenantIsolationIntegrationTest: a real k3s API server via Testcontainers,
// the same pattern operator/build.gradle.kts and ingest/build.gradle.kts already use.
testImplementation(platform(libs.testcontainers.bom))
testImplementation(libs.testcontainers.junit)
testImplementation(libs.testcontainers.k3s)
}

// io.micronaut.test:micronaut-test-bom imports its own, newer org.testcontainers:testcontainers-
// bom (2.0.5) than this project pins everywhere else (1.20.4, see settings.gradle.kts) -- as two
// competing platform constraints on the same modules, Gradle would otherwise pick the higher one,
// silently upgrading Testcontainers for this module's tests only, off of a major version this
// project has not verified against (2.x renamed/restructured artifacts, breaking this
// configuration's resolution outright). This module does not use micronaut-test's own
// Testcontainers integration -- forcing every org.testcontainers module back to the pinned
// version keeps exactly one Testcontainers version across the whole project.
configurations.matching { it.name == "testCompileClasspath" || it.name == "testRuntimeClasspath" }.configureEach {
resolutionStrategy.eachDependency {
if (requested.group == "org.testcontainers") {
useVersion(libs.versions.testcontainers.get())
because("pin to the project-wide Testcontainers version, see settings.gradle.kts")
}
}
}

application {
mainClass.set("net.onelitefeather.apus.api.Application")
}

// TenantIsolationIntegrationTest starts a k3s container (via Testcontainers), applies the
// `:operator` module's generated CRDs to it, and proves cross-tenant isolation over a real,
// JWT-authenticated HTTP call against a real API server -- minutes of work and Docker, exactly
// like operator/build.gradle.kts's and ingest/build.gradle.kts's own `integrationTest` tasks.
// Excluded from the default `test` task/`build`/`check` for the same reason theirs are.
val operatorGenerateCrds = project(":operator").tasks.named("generateCrds")
val operatorCrdDir = project(":operator").layout.buildDirectory.dir("crds")

tasks.test {
exclude("**/*IntegrationTest.class")
}

val integrationTest by tasks.registering(Test::class) {
group = "verification"
description = "Runs the *IntegrationTest classes against a real k3s cluster started via Testcontainers. " +
"Requires Docker. Not part of build/check."
testClassesDirs = sourceSets.test.get().output.classesDirs
classpath = sourceSets.test.get().runtimeClasspath
dependsOn(operatorGenerateCrds)
systemProperty("apus.crd.dir", operatorCrdDir.get().asFile.absolutePath)
include("**/*IntegrationTest.class")
timeout.set(Duration.ofMinutes(10))
outputs.upToDateWhen { false }
}
30 changes: 30 additions & 0 deletions api/src/main/java/net/onelitefeather/apus/api/Application.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Apus - render and host BlueMap maps on Kubernetes.
* Copyright (C) 2026 OneLiteFeather and contributors
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* <p>
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package net.onelitefeather.apus.api;

import io.micronaut.runtime.Micronaut;

/** Entry point for the Apus REST/SSE API (design spec §11). */
public final class Application {

private Application() {}

public static void main(String[] args) {
Micronaut.run(Application.class, args);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Apus - render and host BlueMap maps on Kubernetes.
* Copyright (C) 2026 OneLiteFeather and contributors
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* <p>
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package net.onelitefeather.apus.api.events;

import io.fabric8.kubernetes.api.model.ListOptionsBuilder;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.Watch;
import io.fabric8.kubernetes.client.Watcher;
import jakarta.inject.Singleton;
import java.util.Optional;
import net.onelitefeather.apus.operator.api.BlueMapRender;

/** Thin {@link RenderRepository} adapter over the fabric8 {@link KubernetesClient}. */
@Singleton
final class Fabric8RenderRepository implements RenderRepository {

private final KubernetesClient client;

Fabric8RenderRepository(KubernetesClient client) {
this.client = client;
}

@Override
public Optional<BlueMapRender> find(String namespace, String name) {
return Optional.ofNullable(
client.resources(BlueMapRender.class).inNamespace(namespace).withName(name).get());
}

@Override
public Watch watch(String namespace, String name, String resourceVersion, Watcher<BlueMapRender> watcher) {
return client.resources(BlueMapRender.class)
.inNamespace(namespace)
.withName(name)
.watch(new ListOptionsBuilder().withResourceVersion(resourceVersion).build(), watcher);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Apus - render and host BlueMap maps on Kubernetes.
* Copyright (C) 2026 OneLiteFeather and contributors
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* <p>
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package net.onelitefeather.apus.api.events;

import io.fabric8.kubernetes.api.model.Pod;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.dsl.LogWatch;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.List;

/**
* Fallback {@link LogSource}: reads a render job's pod logs directly through the Kubernetes
* client, used only when no Loki instance is configured (see {@link LogSourceFactory}). Needs
* {@code get}/{@code list} on {@code pods} and {@code get} on {@code pods/log} in tenant
* namespaces for the API's ServiceAccount -- permissions the Loki path avoids entirely (design
* spec §11.1: "damit braucht die API keinen direkten Pod-Zugriff"). See the task 3 report for the
* full trade-off.
*
* <p>Finds the pod via the {@code job-name} label Kubernetes sets on every pod a {@code Job}
* creates (kept for backward compatibility alongside the newer {@code batch.kubernetes.io/
* job-name} as of Kubernetes 1.27+; {@code RenderJobBuilder} in {@code :operator} does not
* override it, so the plain, older key is used here). If the job's pod was replaced (a retry
* after a crash, design spec §7.3) mid-stream, this does not follow the new pod -- a known gap,
* see the report.
*/
final class KubernetesPodLogSource implements LogSource {

/** Set by Kubernetes itself on every Pod a Job creates -- not an Apus-specific label. */
private static final String JOB_NAME_LABEL = "job-name";

private final KubernetesClient client;

KubernetesPodLogSource(KubernetesClient client) {
this.client = client;
}

@Override
public AutoCloseable tail(String namespace, String jobName, SseSource.Sink<String> sink) {
List<Pod> pods = client.pods()
.inNamespace(namespace)
.withLabel(JOB_NAME_LABEL, jobName)
.list()
.getItems();
if (pods.isEmpty()) {
sink.error(new IllegalStateException("no pod found for render job '" + jobName + "'"));
return () -> {};
}

String podName = pods.get(0).getMetadata().getName();
LogWatch logWatch = client.pods().inNamespace(namespace).withName(podName).watchLog();
Thread reader = Thread.ofVirtual().name("render-log-tail-" + jobName).start(() -> readLines(logWatch, sink));

return () -> {
logWatch.close();
reader.interrupt();
};
}

private static void readLines(LogWatch logWatch, SseSource.Sink<String> sink) {
try (BufferedReader reader =
new BufferedReader(new InputStreamReader(logWatch.getOutput(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
sink.next(line);
}
sink.complete();
} catch (IOException e) {
// Expected, not exceptional, when the cleanup handle above already closed logWatch
// (client disconnected / render went terminal) -- the read is unblocked by the
// stream closing and surfaces as an IOException. sink itself is already a no-op past
// that point (SseSource.SingleSubscription.done), so this is harmless either way.
sink.error(e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Apus - render and host BlueMap maps on Kubernetes.
* Copyright (C) 2026 OneLiteFeather and contributors
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* <p>
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package net.onelitefeather.apus.api.events;

/**
* Where {@code GET /api/renders/{id}/logs} reads log lines from. Two implementations exist,
* chosen once at startup by {@link LogSourceFactory} depending on whether a Loki instance is
* configured -- see that class, and the "log source" section of the task 3 report, for the
* decision and its consequences for the API's ServiceAccount permissions.
*/
interface LogSource {

/**
* Starts tailing log lines for the given render's job, pushing each into {@code sink} as it
* arrives. Returns a handle that stops the tail and releases whatever connection/thread it
* holds when closed -- called by {@link SseSource} once the SSE stream ends, whether that is
* because the client disconnected or the render became terminal.
*
* @param namespace the tenant namespace {@code jobName} lives in, already resolved and
* tenant-checked by the caller
* @param jobName {@link net.onelitefeather.apus.operator.api.BlueMapRenderStatus#getJobName()}
* of the render being tailed
* @param sink receives one {@link SseSource.Sink#next} call per log line, in arrival order
*/
AutoCloseable tail(String namespace, String jobName, SseSource.Sink<String> sink);
}
Loading