Skip to content
Closed
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
46 changes: 19 additions & 27 deletions .gitguardian.yaml
Original file line number Diff line number Diff line change
@@ -1,30 +1,22 @@
# GitGuardian configuration for this repository.
# Schema reference: https://docs.gitguardian.com/ggshield-docs/reference/secret/ignore
# (config file format defined by ggshield, the engine GitGuardian's scanning uses)
version: 2

secret:
# These two entries silence the "2 secrets uncovered" finding GitGuardian raised against the
# commit history introduced in PR #1 (feat/phase-1-render-kern) and inherited by every PR
# stacked on top of it (#2-#5).
#
# Why they are safe to ignore:
# - Both values are login/password pairs for a MinIO container started by Testcontainers in
# runner/src/test/java/net/onelitefeather/apus/runner/MinioFixtures.java. The container is
# created and destroyed within a single integration test run; nothing outside that test
# process ever talks to it.
# - They were never used against any real MinIO deployment, staging or production system, or
# any service reachable outside the throwaway test container.
# - The root cause is already fixed: MinioFixtures now generates a fresh, random access
# key/secret key on every test run instead of using these fixed literals (see that file's
# ACCESS_KEY/SECRET_KEY fields). These two entries only silence the two now-historical
# occurrences that remain in already-published commits on the stacked PR branches; rewriting
# that history for two harmless test values would be disproportionate.
#
# Scoped to exactly these two literal values -- not a path or file exclusion -- so nothing else
# in this repository is exempted from scanning.
ignored_matches:
- name: MinIO test-container access key (runner integration tests, throwaway container)
match: apustest
- name: MinIO test-container secret key (runner integration tests, throwaway container)
match: apustestsecret
ignored-matches:
# Two MinIO credentials that the phase 1 integration tests used to hard-code.
# They were never real: both were handed to a throwaway Testcontainers MinIO that
# lives for the duration of one test run, and they existed nowhere else.
#
# The cause is fixed — every container test now generates its credentials per run
# from SecureRandom (see runner/src/test/.../MinioFixtures.java and the equivalents
# in the ingest and api modules). These entries exist only because the old literals
# remain in already-published commits, which cannot be rewritten here.
#
# Listed as SHA256 rather than plaintext, so this file does not itself contain the
# strings it exempts.
#
# Do not extend this list to silence new findings. A finding in new code means the
# code is wrong, not the scanner.
- name: "phase 1 MinIO test access key (throwaway container, cause fixed)"
match: bfd5d64da90af877034e91f582242391fea586e6d187a475a3407bf37bd6f422
- name: "phase 1 MinIO test secret key (throwaway container, cause fixed)"
match: 9c721c67b04a5ff4622f5796d44a042c328d5453795ac798e14c7a7a31125bdd
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