diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/tenant/TenantResponse.java b/api/src/main/java/net/onelitefeather/apus/api/rest/tenant/TenantResponse.java
index 9b6a0b9..58e0b1e 100644
--- a/api/src/main/java/net/onelitefeather/apus/api/rest/tenant/TenantResponse.java
+++ b/api/src/main/java/net/onelitefeather/apus/api/rest/tenant/TenantResponse.java
@@ -27,6 +27,13 @@
* Its own type, not the custom resource itself -- {@code Tenant} carries a finalizer,
* {@code resourceVersion}, and other managed fields that are the operator's business, not an
* API consumer's, and would change shape with every CRD revision if reused directly here.
+ *
+ *
{@code redirectUris} is the one field here that is not merely informational: it names the
+ * two URIs an administrator still has to register with the identity provider before anyone can
+ * sign in to that tenant's own application instance. The operator cannot register them, and a
+ * missing registration fails at sign-in with {@code AADSTS50011} and leaves nothing in the
+ * cluster's logs -- so it has to reach the console, where the person who created the tenant is
+ * standing. Empty for a tenant with no instance, never null.
*/
@Serdeable
public record TenantResponse(
@@ -38,6 +45,7 @@ public record TenantResponse(
String namespace,
String objectStoreUser,
Long storageUsedBytes,
+ List redirectUris,
List conditions) {
public static TenantResponse from(Tenant tenant) {
@@ -52,6 +60,7 @@ public static TenantResponse from(Tenant tenant) {
status.getNamespace(),
status.getObjectStoreUser(),
status.getStorageUsedBytes(),
+ List.copyOf(status.getRedirectUris()),
status.getConditions().stream().map(ConditionResponse::from).toList());
}
diff --git a/api/src/test/java/net/onelitefeather/apus/api/rest/tenant/TenantControllerTest.java b/api/src/test/java/net/onelitefeather/apus/api/rest/tenant/TenantControllerTest.java
index 4d61ea1..2a8008d 100644
--- a/api/src/test/java/net/onelitefeather/apus/api/rest/tenant/TenantControllerTest.java
+++ b/api/src/test/java/net/onelitefeather/apus/api/rest/tenant/TenantControllerTest.java
@@ -143,4 +143,41 @@ void updateRejectsAnUnknownTenant() {
var request = new UpdateTenantRequest("500Gi", null, null, null);
assertThrows(NotFoundException.class, () -> controller.update(platformAdmin(), "does-not-exist", request));
}
+
+ /**
+ * The operator reports the redirect URIs a tenant's own application instance needs, because
+ * it cannot register them with the identity provider itself. They have to reach the console,
+ * or the person who just created a tenant walks away without being told what remains -- and
+ * the failure they eventually hit (AADSTS50011 at sign-in) leaves no trace in this cluster.
+ */
+ @Test
+ void listReportsTheRedirectUrisTheOperatorPublished() {
+ Tenant tenant = new Tenant();
+ tenant.getMetadata().setName("acme");
+ tenant.getStatus()
+ .setRedirectUris(List.of(
+ "https://apus.example.dev/t/acme/auth/callback",
+ "https://apus.example.dev/t/acme/auth/silent-renew"));
+ repository.put(tenant);
+
+ var response = controller.list(platformAdmin());
+
+ assertEquals(
+ List.of(
+ "https://apus.example.dev/t/acme/auth/callback",
+ "https://apus.example.dev/t/acme/auth/silent-renew"),
+ response.body().get(0).redirectUris());
+ }
+
+ /** A tenant with no application instance reports an empty list, never null. */
+ @Test
+ void listReportsNoRedirectUrisForATenantWithoutAnInstance() {
+ Tenant tenant = new Tenant();
+ tenant.getMetadata().setName("acme");
+ repository.put(tenant);
+
+ var response = controller.list(platformAdmin());
+
+ assertTrue(response.body().get(0).redirectUris().isEmpty());
+ }
}
diff --git a/deploy/charts/apus-operator/files/crds/tenants.bluemap.onelitefeather.net-v1.yaml b/deploy/charts/apus-operator/files/crds/tenants.bluemap.onelitefeather.net-v1.yaml
index 32451df..3f888a2 100644
--- a/deploy/charts/apus-operator/files/crds/tenants.bluemap.onelitefeather.net-v1.yaml
+++ b/deploy/charts/apus-operator/files/crds/tenants.bluemap.onelitefeather.net-v1.yaml
@@ -76,6 +76,10 @@ spec:
type: string
pushTokenSecret:
type: string
+ redirectUris:
+ items:
+ type: string
+ type: array
storageUsedBytes:
type: integer
type: object
diff --git a/deploy/charts/apus-operator/templates/NOTES.txt b/deploy/charts/apus-operator/templates/NOTES.txt
index 446525a..30aae59 100644
--- a/deploy/charts/apus-operator/templates/NOTES.txt
+++ b/deploy/charts/apus-operator/templates/NOTES.txt
@@ -39,6 +39,30 @@ The apus-operator is installed.
{{- end }}
{{- end }}
+{{- if .Values.tenantUi.host }}
+
+IMPORTANT: every tenant needs two redirect URIs registered by hand.
+
+tenantUi.host is set, so each Tenant gets its own instance of the tenant application at
+https://{{ .Values.tenantUi.host }}/t//. The operator cannot register that instance's
+redirect URIs with your identity provider -- doing so needs application permissions on the
+app registration that this chart does not ask for and nobody has granted.
+
+Until they are registered, signing in to a tenant's instance fails at the identity provider
+(Entra reports AADSTS50011) and nothing appears in this cluster's logs at all. Read the two
+URIs for a tenant off its status:
+
+ kubectl get tenant -o jsonpath='{.status.redirectUris}'
+
+They are always these two:
+
+ https://{{ .Values.tenantUi.host }}/t//auth/callback
+ https://{{ .Values.tenantUi.host }}/t//auth/silent-renew
+
+A wildcard is not a shortcut here: Entra strips the query string when a wildcard redirect URI
+matches, and the authorization code lives in that query string.
+{{- end }}
+
This chart only installs the operator: the CRDs, the controller and its RBAC. It has no
user interface. The `apus-platform` chart installs the REST API and the dashboard that let
you manage tenants, worlds and renders without talking to the Kubernetes API directly.
diff --git a/deploy/charts/apus-operator/templates/deployment.yaml b/deploy/charts/apus-operator/templates/deployment.yaml
index f55c860..d03d36a 100644
--- a/deploy/charts/apus-operator/templates/deployment.yaml
+++ b/deploy/charts/apus-operator/templates/deployment.yaml
@@ -64,6 +64,22 @@ spec:
value: {{ .Values.bundles.s3Region | quote }}
- name: APUS_BUNDLE_CREDENTIALS_SECRET
value: {{ .Values.bundles.credentialsSecret | quote }}
+ # An empty host switches the per-tenant application instance off entirely, which is
+ # the default -- the operator checks this one value and creates nothing at all.
+ - name: APUS_TENANT_UI_HOST
+ value: {{ .Values.tenantUi.host | quote }}
+ - name: APUS_TENANT_UI_IMAGE
+ value: {{ include "apus-operator.image" (dict "image" .Values.tenantUi.image "ctx" .) | quote }}
+ - name: APUS_TENANT_UI_INGRESS_CLASS
+ value: {{ .Values.tenantUi.ingressClassName | quote }}
+ - name: APUS_TENANT_UI_API_BASE_URL
+ value: {{ .Values.tenantUi.apiBaseUrl | quote }}
+ - name: APUS_TENANT_UI_OIDC_ISSUER
+ value: {{ .Values.tenantUi.oidc.issuer | quote }}
+ - name: APUS_TENANT_UI_OIDC_CLIENT_ID
+ value: {{ .Values.tenantUi.oidc.clientId | quote }}
+ - name: APUS_TENANT_UI_OIDC_SCOPE
+ value: {{ .Values.tenantUi.oidc.scope | quote }}
{{- if .Values.otel.endpoint }}
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: {{ .Values.otel.endpoint | quote }}
diff --git a/deploy/charts/apus-operator/values.schema.json b/deploy/charts/apus-operator/values.schema.json
index a51400c..3e39ecc 100644
--- a/deploy/charts/apus-operator/values.schema.json
+++ b/deploy/charts/apus-operator/values.schema.json
@@ -28,6 +28,34 @@
}
}
},
+ "tenantUi": {
+ "type": "object",
+ "description": "One instance of the tenant application per tenant. Not required: an empty host disables it, which is the default.",
+ "properties": {
+ "host": { "type": "string" },
+ "image": {
+ "type": "object",
+ "properties": {
+ "repository": { "type": "string", "minLength": 1 },
+ "tag": { "type": "string" }
+ }
+ },
+ "ingressClassName": { "type": "string", "minLength": 1 },
+ "apiBaseUrl": {
+ "type": "string",
+ "description": "Origin only, with no /api suffix -- the typed client already asks for /api paths, so a suffix here produces /api/api/tenants and a bare 403.",
+ "not": { "pattern": "/api/?$" }
+ },
+ "oidc": {
+ "type": "object",
+ "properties": {
+ "issuer": { "type": "string" },
+ "clientId": { "type": "string" },
+ "scope": { "type": "string" }
+ }
+ }
+ }
+ },
"replicaCount": { "type": "integer", "minimum": 1, "maximum": 1 }
}
}
diff --git a/deploy/charts/apus-operator/values.yaml b/deploy/charts/apus-operator/values.yaml
index 8f3d6e6..46343bc 100644
--- a/deploy/charts/apus-operator/values.yaml
+++ b/deploy/charts/apus-operator/values.yaml
@@ -39,6 +39,43 @@ bundles:
s3Region: us-east-1
credentialsSecret: apus-bundle-credentials
+# One instance of the tenant application per tenant, served at https:///t//.
+# The operator creates a Deployment, a Service and an Ingress in each tenant's own namespace
+# -- the Ingress has to live there, since an Ingress may only reference a Service in its own
+# namespace.
+#
+# Off by default: an empty host disables the feature entirely. An instance with no host would
+# have nothing to serve it, and a Deployment nobody can reach costs a pod per tenant.
+#
+# One image serves every tenant. NUXT_APP_BASE_URL moves the served prefix at runtime, so a
+# tenant instance differs from the platform chart's own `ui` in exactly one variable.
+#
+# Registering the two redirect URIs each instance needs is a manual step -- the operator has no
+# permission on the app registration. They are reported on Tenant.status.redirectUris; see the
+# notes printed after install.
+tenantUi:
+ # e.g. apus.example.dev -- the same host the apus-platform ingress serves.
+ host: ""
+ image:
+ repository: harbor.onelitefeather.dev/apus/ui
+ # Empty falls back to .Chart.AppVersion, like every other image here.
+ tag: ""
+ # Must match the apus-platform ingress's class: both serve paths on the same host.
+ ingressClassName: nginx
+ # Handed to every instance as NUXT_PUBLIC_*. None is a secret -- this is a public OIDC client
+ # and all of it ends up in the served HTML by design. Every value is identical between
+ # tenants; only the base path differs, and the operator computes that.
+ #
+ # The origin only, with no /api suffix: the typed client already asks for paths beginning
+ # with /api, so a suffix here produces /api/api/tenants and a bare 403.
+ apiBaseUrl: ""
+ oidc:
+ issuer: ""
+ clientId: ""
+ # Entra-specific and not optional there: asking for only `openid profile email` returns a
+ # token addressed to Microsoft Graph, which the API rejects.
+ scope: ""
+
metrics:
enabled: true
port: 8080
diff --git a/docs/superpowers/plans/2026-08-16-per-tenant-app-instance.md b/docs/superpowers/plans/2026-08-16-per-tenant-app-instance.md
new file mode 100644
index 0000000..f124d9a
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-16-per-tenant-app-instance.md
@@ -0,0 +1,1130 @@
+# Per-Tenant App UI Instance Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** `TenantReconciler` provisions a Deployment, Service and Ingress per tenant so the tenant application is served at `https:///t//`, and reports the two Entra redirect URIs that must be registered by hand.
+
+**Architecture:** A pure `TenantUiResourceBuilder` turns a `Tenant` plus a `TenantUiConfig` into the three manifests; `TenantReconciler` submits them with the same `createOr(update)` idempotence it already uses, guarded by whether a host is configured at all. Configuration reaches the operator as `APUS_TENANT_UI_*` environment variables, rendered by the operator chart from a `tenantUi` value block.
+
+**Tech Stack:** Java 21, fabric8 Kubernetes client, JOSDK, JUnit 5, fabric8 `KubernetesMockServerExtension`, Helm, Spotless (palantir-java-format).
+
+**Spec:** `docs/superpowers/specs/2026-08-16-per-tenant-app-instance-design.md`
+
+## Global Constraints
+
+- **Every pod in a tenant namespace must declare `resources.requests.cpu` and `.memory`.** The namespace's `ResourceQuota` constrains both and its `LimitRange` is empty (`spec.limits: null`), so a Deployment without requests is accepted and never produces a pod. Use `cpu: 50m` / `memory: 128Mi` requests and `memory: 256Mi` limit, matching `ui.resources` in the platform chart.
+- **The tenant namespace is `bluemap-`**, from `TenantReconciler.namespaceFor(Tenant)`. Never re-derive it.
+- **Container port is 8080**, pinned by `ui/Dockerfile` (`PORT=8080`); Nitro's own default of 3000 is not what the image uses.
+- **Base URL has a trailing slash, the ingress path does not**: `NUXT_APP_BASE_URL=/t/acme/`, ingress path `/t/acme`.
+- **`pathType` must be `Prefix`.** The tunnel controller rejects any other value except `ImplementationSpecific`.
+- Every created resource carries `Labels.standard(...)` plus `Labels.TENANT` and `Labels.TENANT_UID`, and the owner reference built by `TenantReconciler`.
+- Licence header: copy the 17-line AGPL block verbatim from any existing file in the same module.
+- Formatting is enforced by `./gradlew :operator:spotlessJavaCheck`; run `spotlessApply` before committing.
+
+---
+
+### Task 1: `TenantUiConfig` and its wiring into `OperatorConfig`
+
+**Files:**
+
+- Create: `operator/src/main/java/net/onelitefeather/apus/operator/TenantUiConfig.java`
+- Modify: `operator/src/main/java/net/onelitefeather/apus/operator/OperatorConfig.java`
+- Test: `operator/src/test/java/net/onelitefeather/apus/operator/TenantUiConfigTest.java`
+- Test (modify): `operator/src/test/java/net/onelitefeather/apus/operator/OperatorConfigTest.java`
+
+**Interfaces:**
+
+- Consumes: nothing.
+- Produces: `TenantUiConfig(String host, String image, String ingressClassName, String apiBaseUrl, String oidcIssuer, String oidcClientId, String oidcScope)` with `boolean enabled()` and `static TenantUiConfig disabled()` / `static TenantUiConfig fromEnvironment(Function)`; `OperatorConfig.tenantUi()` returning it.
+
+- [ ] **Step 1: Write the failing test**
+
+`operator/src/test/java/net/onelitefeather/apus/operator/TenantUiConfigTest.java`:
+
+```java
+package net.onelitefeather.apus.operator;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class TenantUiConfigTest {
+
+ @Test
+ void isDisabledWhenNoHostIsConfigured() {
+ assertFalse(TenantUiConfig.fromEnvironment(name -> null).enabled());
+ assertEquals(TenantUiConfig.disabled(), TenantUiConfig.fromEnvironment(name -> null));
+ }
+
+ @Test
+ void isDisabledWhenTheHostIsBlank() {
+ Map env = Map.of("APUS_TENANT_UI_HOST", " ");
+
+ assertFalse(TenantUiConfig.fromEnvironment(env::get).enabled());
+ }
+
+ @Test
+ void isEnabledOnceAHostIsSet() {
+ Map env = Map.of("APUS_TENANT_UI_HOST", "apus.example.dev");
+
+ assertTrue(TenantUiConfig.fromEnvironment(env::get).enabled());
+ }
+
+ @Test
+ void readsEveryVariable() {
+ Map env = Map.ofEntries(
+ Map.entry("APUS_TENANT_UI_HOST", "apus.example.dev"),
+ Map.entry("APUS_TENANT_UI_IMAGE", "apus/ui:1.2.3"),
+ Map.entry("APUS_TENANT_UI_INGRESS_CLASS", "cloudflare-tunnel"),
+ Map.entry("APUS_TENANT_UI_API_BASE_URL", "https://apus.example.dev"),
+ Map.entry("APUS_TENANT_UI_OIDC_ISSUER", "https://issuer.example/v2.0"),
+ Map.entry("APUS_TENANT_UI_OIDC_CLIENT_ID", "client-id"),
+ Map.entry("APUS_TENANT_UI_OIDC_SCOPE", "api://client-id/access_as_user openid"));
+
+ TenantUiConfig config = TenantUiConfig.fromEnvironment(env::get);
+
+ assertEquals("apus.example.dev", config.host());
+ assertEquals("apus/ui:1.2.3", config.image());
+ assertEquals("cloudflare-tunnel", config.ingressClassName());
+ assertEquals("https://apus.example.dev", config.apiBaseUrl());
+ assertEquals("https://issuer.example/v2.0", config.oidcIssuer());
+ assertEquals("client-id", config.oidcClientId());
+ assertEquals("api://client-id/access_as_user openid", config.oidcScope());
+ }
+
+ @Test
+ void fallsBackToTheDefaultImageAndIngressClass() {
+ Map env = Map.of("APUS_TENANT_UI_HOST", "apus.example.dev");
+
+ TenantUiConfig config = TenantUiConfig.fromEnvironment(env::get);
+
+ assertEquals("apus/ui:dev", config.image());
+ assertEquals("nginx", config.ingressClassName());
+ }
+}
+```
+
+- [ ] **Step 2: Run it to make sure it fails**
+
+Run: `./gradlew :operator:test --tests '*TenantUiConfigTest*'`
+Expected: FAIL — compilation error, `TenantUiConfig` does not exist.
+
+- [ ] **Step 3: Write the implementation**
+
+`operator/src/main/java/net/onelitefeather/apus/operator/TenantUiConfig.java` (licence header omitted here — copy it verbatim from `OperatorConfig.java`):
+
+```java
+package net.onelitefeather.apus.operator;
+
+import java.util.function.Function;
+
+/**
+ * Settings for the per-tenant application instance {@code TenantReconciler} provisions: which
+ * image to run it from, which host and ingress class to expose it on, and the public runtime
+ * configuration every instance is handed.
+ *
+ * The four {@code NUXT_PUBLIC_*} values are modelled one by one rather than as a free-form
+ * map: {@link OperatorConfig} is built entirely from environment variables, so a map would have
+ * to be serialised through a single variable and would lose its schema, its documentation and
+ * the ability to test each value on its own. All four are identical for every tenant -- same
+ * API, same issuer, same OIDC client -- and none is a secret; every one of them reaches the
+ * served HTML by design.
+ *
+ * @param host the host tenant paths hang off. Blank disables the feature entirely: an
+ * instance with no host would have nothing to serve it, and creating a Deployment nobody can
+ * reach would burn a pod per tenant for nothing
+ * @param image the tenant application image, the same one the platform chart deploys as {@code ui}
+ * @param ingressClassName the ingress class of the per-tenant {@code Ingress}; must match the
+ * platform's, since both serve paths on {@link #host}
+ * @param apiBaseUrl becomes {@code NUXT_PUBLIC_API_BASE_URL}. The origin only, with no {@code
+ * /api} suffix -- the typed client already asks for paths beginning with {@code /api}
+ * @param oidcIssuer becomes {@code NUXT_PUBLIC_OIDC_ISSUER}
+ * @param oidcClientId becomes {@code NUXT_PUBLIC_OIDC_CLIENT_ID}
+ * @param oidcScope becomes {@code NUXT_PUBLIC_OIDC_SCOPE}
+ */
+public record TenantUiConfig(
+ String host,
+ String image,
+ String ingressClassName,
+ String apiBaseUrl,
+ String oidcIssuer,
+ String oidcClientId,
+ String oidcScope) {
+
+ private static final String DEFAULT_IMAGE = "apus/ui:dev";
+ private static final String DEFAULT_INGRESS_CLASS = "nginx";
+
+ /** The feature switched off: no host, so no per-tenant instance is provisioned at all. */
+ public static TenantUiConfig disabled() {
+ return new TenantUiConfig("", DEFAULT_IMAGE, DEFAULT_INGRESS_CLASS, "", "", "", "");
+ }
+
+ /**
+ * Recognised variables: {@code APUS_TENANT_UI_HOST}, {@code APUS_TENANT_UI_IMAGE}, {@code
+ * APUS_TENANT_UI_INGRESS_CLASS}, {@code APUS_TENANT_UI_API_BASE_URL}, {@code
+ * APUS_TENANT_UI_OIDC_ISSUER}, {@code APUS_TENANT_UI_OIDC_CLIENT_ID}, {@code
+ * APUS_TENANT_UI_OIDC_SCOPE}.
+ */
+ public static TenantUiConfig fromEnvironment(Function env) {
+ return new TenantUiConfig(
+ valueOrDefault(env.apply("APUS_TENANT_UI_HOST"), ""),
+ valueOrDefault(env.apply("APUS_TENANT_UI_IMAGE"), DEFAULT_IMAGE),
+ valueOrDefault(env.apply("APUS_TENANT_UI_INGRESS_CLASS"), DEFAULT_INGRESS_CLASS),
+ valueOrDefault(env.apply("APUS_TENANT_UI_API_BASE_URL"), ""),
+ valueOrDefault(env.apply("APUS_TENANT_UI_OIDC_ISSUER"), ""),
+ valueOrDefault(env.apply("APUS_TENANT_UI_OIDC_CLIENT_ID"), ""),
+ valueOrDefault(env.apply("APUS_TENANT_UI_OIDC_SCOPE"), ""));
+ }
+
+ /** Whether a per-tenant instance should be provisioned at all -- see {@link #host}. */
+ public boolean enabled() {
+ return host != null && !host.isBlank();
+ }
+
+ private static String valueOrDefault(String value, String defaultValue) {
+ return (value == null || value.isBlank()) ? defaultValue : value;
+ }
+}
+```
+
+Then add the component to `OperatorConfig`: a `TenantUiConfig tenantUi` parameter at the end of the record header, `TenantUiConfig.disabled()` in `defaults()`, and `TenantUiConfig.fromEnvironment(env)` in `fromEnvironment`. Add the Javadoc `@param tenantUi settings for the per-tenant application instance; see {@link TenantUiConfig}`.
+
+- [ ] **Step 4: Extend `OperatorConfigTest`**
+
+Add to `defaultsMatchTheFeatherCoreCluster`:
+
+```java
+ assertFalse(config.tenantUi().enabled());
+```
+
+and a new test:
+
+```java
+ @Test
+ void fromEnvironmentCarriesTheTenantUiSettings() {
+ Map env = Map.of("APUS_TENANT_UI_HOST", "apus.example.dev");
+
+ OperatorConfig config = OperatorConfig.fromEnvironment(env::get);
+
+ assertTrue(config.tenantUi().enabled());
+ assertEquals("apus.example.dev", config.tenantUi().host());
+ }
+```
+
+Add the `assertFalse`/`assertTrue` static imports.
+
+- [ ] **Step 5: Run the tests**
+
+Run: `./gradlew :operator:test --tests '*TenantUiConfigTest*' --tests '*OperatorConfigTest*'`
+Expected: PASS.
+
+- [ ] **Step 6: Format and commit**
+
+```bash
+./gradlew :operator:spotlessApply
+git add operator/src/main/java/net/onelitefeather/apus/operator/TenantUiConfig.java \
+ operator/src/main/java/net/onelitefeather/apus/operator/OperatorConfig.java \
+ operator/src/test/java/net/onelitefeather/apus/operator/TenantUiConfigTest.java \
+ operator/src/test/java/net/onelitefeather/apus/operator/OperatorConfigTest.java
+git commit --no-gpg-sign -m "feat(operator): configure the per-tenant application instance"
+```
+
+---
+
+### Task 2: `TenantUiResourceBuilder`
+
+**Files:**
+
+- Create: `operator/src/main/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilder.java`
+- Test: `operator/src/test/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilderTest.java`
+
+**Interfaces:**
+
+- Consumes: `TenantUiConfig` (Task 1); `Tenant`, `Labels`, `TenantReconciler.namespaceFor`.
+- Produces:
+ - `static String basePath(Tenant)` → `/t//`
+ - `static String ingressPath(Tenant)` → `/t/`
+ - `static List redirectUris(Tenant, TenantUiConfig)` → the two `https:///t//auth/{callback,silent-renew}`
+ - `static Deployment deployment(Tenant, TenantUiConfig, Map labels, OwnerReference owner)`
+ - `static Service service(Tenant, Map labels, OwnerReference owner)`
+ - `static Ingress ingress(Tenant, TenantUiConfig, Map labels, OwnerReference owner)`
+ - `static final String RESOURCE_NAME = "apus-tenant-ui"`, `static final int CONTAINER_PORT = 8080`
+
+ Labels and the owner reference are passed in rather than rebuilt, so the builder cannot drift from what `TenantReconciler` stamps on everything else.
+
+- [ ] **Step 1: Write the failing test**
+
+`operator/src/test/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilderTest.java`:
+
+```java
+package net.onelitefeather.apus.operator.tenant;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.fabric8.kubernetes.api.model.Container;
+import io.fabric8.kubernetes.api.model.EnvVar;
+import io.fabric8.kubernetes.api.model.OwnerReference;
+import io.fabric8.kubernetes.api.model.OwnerReferenceBuilder;
+import io.fabric8.kubernetes.api.model.apps.Deployment;
+import io.fabric8.kubernetes.api.model.networking.v1.Ingress;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import net.onelitefeather.apus.operator.TenantUiConfig;
+import net.onelitefeather.apus.operator.api.Labels;
+import net.onelitefeather.apus.operator.api.Tenant;
+import org.junit.jupiter.api.Test;
+
+class TenantUiResourceBuilderTest {
+
+ private static final OwnerReference OWNER = new OwnerReferenceBuilder()
+ .withApiVersion("bluemap.onelitefeather.net/v1alpha1")
+ .withKind("Tenant")
+ .withName("acme")
+ .withUid("uid-1")
+ .withController(true)
+ .build();
+
+ private static final Map LABELS = Map.of(
+ Labels.MANAGED_BY, Labels.MANAGED_BY_VALUE,
+ Labels.TENANT, "acme",
+ Labels.TENANT_UID, "uid-1");
+
+ private static Tenant tenant() {
+ Tenant tenant = new Tenant();
+ tenant.getMetadata().setName("acme");
+ tenant.getMetadata().setUid("uid-1");
+ return tenant;
+ }
+
+ private static TenantUiConfig config() {
+ return new TenantUiConfig(
+ "apus.example.dev",
+ "apus/ui:1.2.3",
+ "cloudflare-tunnel",
+ "https://apus.example.dev",
+ "https://issuer.example/v2.0",
+ "client-id",
+ "api://client-id/access_as_user openid");
+ }
+
+ private static Map envOf(Deployment deployment) {
+ Container container =
+ deployment.getSpec().getTemplate().getSpec().getContainers().get(0);
+ return container.getEnv().stream().collect(Collectors.toMap(EnvVar::getName, EnvVar::getValue));
+ }
+
+ @Test
+ void theBasePathHasATrailingSlashAndTheIngressPathDoesNot() {
+ assertEquals("/t/acme/", TenantUiResourceBuilder.basePath(tenant()));
+ assertEquals("/t/acme", TenantUiResourceBuilder.ingressPath(tenant()));
+ }
+
+ @Test
+ void theDeploymentServesTheTenantsOwnBasePath() {
+ Deployment deployment = TenantUiResourceBuilder.deployment(tenant(), config(), LABELS, OWNER);
+
+ assertEquals("/t/acme/", envOf(deployment).get("NUXT_APP_BASE_URL"));
+ }
+
+ @Test
+ void theDeploymentCarriesThePublicRuntimeConfiguration() {
+ Map env = envOf(TenantUiResourceBuilder.deployment(tenant(), config(), LABELS, OWNER));
+
+ assertEquals("https://apus.example.dev", env.get("NUXT_PUBLIC_API_BASE_URL"));
+ assertEquals("https://issuer.example/v2.0", env.get("NUXT_PUBLIC_OIDC_ISSUER"));
+ assertEquals("client-id", env.get("NUXT_PUBLIC_OIDC_CLIENT_ID"));
+ assertEquals("api://client-id/access_as_user openid", env.get("NUXT_PUBLIC_OIDC_SCOPE"));
+ }
+
+ @Test
+ void theDeploymentRunsTheConfiguredImageInTheTenantNamespace() {
+ Deployment deployment = TenantUiResourceBuilder.deployment(tenant(), config(), LABELS, OWNER);
+
+ assertEquals("bluemap-acme", deployment.getMetadata().getNamespace());
+ assertEquals(
+ "apus/ui:1.2.3",
+ deployment.getSpec().getTemplate().getSpec().getContainers().get(0).getImage());
+ }
+
+ /**
+ * The tenant namespace's ResourceQuota constrains requests.cpu and requests.memory, and the
+ * LimitRange beside it is empty (spec.limits: null). A quota on a compute resource makes that
+ * request mandatory for every pod, and an empty limit range supplies no default -- so a
+ * Deployment without requests is created happily and then never produces a pod.
+ */
+ @Test
+ void theDeploymentDeclaresResourceRequestsOrTheQuotaWouldRejectEveryPod() {
+ Deployment deployment = TenantUiResourceBuilder.deployment(tenant(), config(), LABELS, OWNER);
+ Container container =
+ deployment.getSpec().getTemplate().getSpec().getContainers().get(0);
+
+ assertNotNull(container.getResources());
+ assertEquals("50m", container.getResources().getRequests().get("cpu").toString());
+ assertEquals("128Mi", container.getResources().getRequests().get("memory").toString());
+ }
+
+ @Test
+ void everyResourceIsOwnedByTheTenantAndLabelledLikeTheRest() {
+ var deployment = TenantUiResourceBuilder.deployment(tenant(), config(), LABELS, OWNER);
+ var service = TenantUiResourceBuilder.service(tenant(), LABELS, OWNER);
+ var ingress = TenantUiResourceBuilder.ingress(tenant(), config(), LABELS, OWNER);
+
+ for (var meta : List.of(deployment.getMetadata(), service.getMetadata(), ingress.getMetadata())) {
+ assertEquals("apus-tenant-ui", meta.getName());
+ assertEquals("bluemap-acme", meta.getNamespace());
+ assertEquals(LABELS, meta.getLabels());
+ assertEquals(List.of(OWNER), meta.getOwnerReferences());
+ }
+ }
+
+ @Test
+ void theIngressRoutesTheTenantPathOnTheConfiguredHost() {
+ Ingress ingress = TenantUiResourceBuilder.ingress(tenant(), config(), LABELS, OWNER);
+ var rule = ingress.getSpec().getRules().get(0);
+ var path = rule.getHttp().getPaths().get(0);
+
+ assertEquals("cloudflare-tunnel", ingress.getSpec().getIngressClassName());
+ assertEquals("apus.example.dev", rule.getHost());
+ assertEquals("/t/acme", path.getPath());
+ assertEquals("Prefix", path.getPathType());
+ assertEquals("apus-tenant-ui", path.getBackend().getService().getName());
+ }
+
+ /** TLS terminates at the edge; a tls section here would ask for a certificate nobody issues. */
+ @Test
+ void theIngressAsksForNoTls() {
+ Ingress ingress = TenantUiResourceBuilder.ingress(tenant(), config(), LABELS, OWNER);
+
+ assertTrue(ingress.getSpec().getTls() == null
+ || ingress.getSpec().getTls().isEmpty());
+ }
+
+ @Test
+ void theRedirectUrisAreTheTwoEntraMustHaveRegistered() {
+ assertEquals(
+ List.of(
+ "https://apus.example.dev/t/acme/auth/callback",
+ "https://apus.example.dev/t/acme/auth/silent-renew"),
+ TenantUiResourceBuilder.redirectUris(tenant(), config()));
+ }
+}
+```
+
+- [ ] **Step 2: Run it to make sure it fails**
+
+Run: `./gradlew :operator:test --tests '*TenantUiResourceBuilderTest*'`
+Expected: FAIL — `TenantUiResourceBuilder` does not exist.
+
+- [ ] **Step 3: Write the implementation**
+
+`operator/src/main/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilder.java` (licence header verbatim from `TenantReconciler.java`):
+
+```java
+package net.onelitefeather.apus.operator.tenant;
+
+import io.fabric8.kubernetes.api.model.Container;
+import io.fabric8.kubernetes.api.model.ContainerBuilder;
+import io.fabric8.kubernetes.api.model.ContainerPort;
+import io.fabric8.kubernetes.api.model.ContainerPortBuilder;
+import io.fabric8.kubernetes.api.model.EnvVar;
+import io.fabric8.kubernetes.api.model.EnvVarBuilder;
+import io.fabric8.kubernetes.api.model.OwnerReference;
+import io.fabric8.kubernetes.api.model.Probe;
+import io.fabric8.kubernetes.api.model.ProbeBuilder;
+import io.fabric8.kubernetes.api.model.Quantity;
+import io.fabric8.kubernetes.api.model.ResourceRequirements;
+import io.fabric8.kubernetes.api.model.ResourceRequirementsBuilder;
+import io.fabric8.kubernetes.api.model.Service;
+import io.fabric8.kubernetes.api.model.ServiceBuilder;
+import io.fabric8.kubernetes.api.model.ServicePort;
+import io.fabric8.kubernetes.api.model.ServicePortBuilder;
+import io.fabric8.kubernetes.api.model.apps.Deployment;
+import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder;
+import io.fabric8.kubernetes.api.model.networking.v1.HTTPIngressPathBuilder;
+import io.fabric8.kubernetes.api.model.networking.v1.Ingress;
+import io.fabric8.kubernetes.api.model.networking.v1.IngressBackendBuilder;
+import io.fabric8.kubernetes.api.model.networking.v1.IngressBuilder;
+import io.fabric8.kubernetes.api.model.networking.v1.IngressRuleBuilder;
+import io.fabric8.kubernetes.api.model.networking.v1.IngressServiceBackendBuilder;
+import java.util.List;
+import java.util.Map;
+import net.onelitefeather.apus.operator.TenantUiConfig;
+import net.onelitefeather.apus.operator.api.Tenant;
+
+/**
+ * Turns a {@link Tenant} into the Kubernetes objects that serve it its own instance of the tenant
+ * application at {@code https:///t//}: a {@link Deployment}, a {@link Service} and
+ * an {@link Ingress}.
+ *
+ * Pure function, no Kubernetes client and no side effects, following {@code
+ * HostingResourceBuilder}. Labels and the owner reference are passed in rather than rebuilt here,
+ * so what lands on these three objects cannot drift from what {@link TenantReconciler} stamps on
+ * the namespace, quota and limit range.
+ *
+ *
One image serves every tenant: {@code NUXT_APP_BASE_URL} moves the served prefix at runtime,
+ * so a tenant instance differs from the platform's own {@code ui} Deployment in exactly one
+ * environment variable.
+ */
+public final class TenantUiResourceBuilder {
+
+ /** Name shared by the Deployment, the Service and the Ingress in a tenant's own namespace. */
+ public static final String RESOURCE_NAME = "apus-tenant-ui";
+
+ /** Pinned by {@code ui/Dockerfile} ({@code PORT=8080}); Nitro's own default of 3000 is unused. */
+ public static final int CONTAINER_PORT = 8080;
+
+ private static final String CONTAINER_NAME = "ui";
+
+ /**
+ * Requests are not optional here. A tenant namespace carries a {@code ResourceQuota} on
+ * {@code requests.cpu}/{@code requests.memory} and a {@code LimitRange} with no spec at all,
+ * so the quota makes both requests mandatory and nothing supplies a default. A Deployment
+ * without them is accepted by the API server and then never produces a pod. Values match
+ * {@code ui.resources} in the platform chart, where they were measured against Nitro's actual
+ * shell-per-request profile.
+ */
+ private static final String CPU_REQUEST = "50m";
+
+ private static final String MEMORY_REQUEST = "128Mi";
+
+ private static final String MEMORY_LIMIT = "256Mi";
+
+ private TenantUiResourceBuilder() {}
+
+ /** The prefix this tenant's instance is served under, with the trailing slash Nuxt expects. */
+ public static String basePath(Tenant tenant) {
+ return "/t/" + tenant.getMetadata().getName() + "/";
+ }
+
+ /** The same prefix as an ingress path, which carries no trailing slash. */
+ public static String ingressPath(Tenant tenant) {
+ return "/t/" + tenant.getMetadata().getName();
+ }
+
+ /**
+ * The two redirect URIs the identity provider must have registered before anyone can sign in
+ * to this tenant's instance. Wildcards are not an option: Entra strips the query string when
+ * a wildcard URI matches, and the authorization code lives in that query string. Reported on
+ * {@code Tenant.status} because a missing registration fails at sign-in with {@code
+ * AADSTS50011} and leaves no trace in this cluster at all.
+ */
+ public static List redirectUris(Tenant tenant, TenantUiConfig config) {
+ String prefix = "https://" + config.host() + basePath(tenant);
+ return List.of(prefix + "auth/callback", prefix + "auth/silent-renew");
+ }
+
+ public static Deployment deployment(
+ Tenant tenant, TenantUiConfig config, Map labels, OwnerReference owner) {
+ Container container = new ContainerBuilder()
+ .withName(CONTAINER_NAME)
+ .withImage(config.image())
+ .withPorts(containerPort())
+ .withEnv(env(tenant, config))
+ .withResources(resources())
+ .withReadinessProbe(probe(tenant))
+ .withLivenessProbe(probe(tenant))
+ .build();
+
+ return new DeploymentBuilder()
+ .withNewMetadata()
+ .withName(RESOURCE_NAME)
+ .withNamespace(TenantReconciler.namespaceFor(tenant))
+ .withLabels(labels)
+ .withOwnerReferences(owner)
+ .endMetadata()
+ .withNewSpec()
+ .withReplicas(1)
+ .withNewSelector()
+ .withMatchLabels(labels)
+ .endSelector()
+ .withNewTemplate()
+ .withNewMetadata()
+ .withLabels(labels)
+ .endMetadata()
+ .withNewSpec()
+ .withContainers(container)
+ .endSpec()
+ .endTemplate()
+ .endSpec()
+ .build();
+ }
+
+ public static Service service(Tenant tenant, Map labels, OwnerReference owner) {
+ ServicePort port = new ServicePortBuilder()
+ .withName("http")
+ .withPort(CONTAINER_PORT)
+ .withNewTargetPort(CONTAINER_PORT)
+ .build();
+
+ return new ServiceBuilder()
+ .withNewMetadata()
+ .withName(RESOURCE_NAME)
+ .withNamespace(TenantReconciler.namespaceFor(tenant))
+ .withLabels(labels)
+ .withOwnerReferences(owner)
+ .endMetadata()
+ .withNewSpec()
+ .withSelector(labels)
+ .withPorts(port)
+ .endSpec()
+ .build();
+ }
+
+ /**
+ * The per-tenant {@link Ingress}. It has to be per-tenant and it has to live in the tenant's
+ * own namespace: an Ingress may only reference a Service in its own namespace, and each
+ * tenant's Service is in {@code bluemap-}. A single operator-owned Ingress listing every
+ * tenant's path is therefore not available at any price.
+ *
+ * No annotations and no {@code tls} section: this cluster's tunnel controller already
+ * defaults {@code backend-protocol} to {@code http}, and TLS terminates at the edge, so a
+ * {@code tls} section here would ask for a certificate nobody issues.
+ */
+ public static Ingress ingress(
+ Tenant tenant, TenantUiConfig config, Map labels, OwnerReference owner) {
+ var backend = new IngressBackendBuilder()
+ .withService(new IngressServiceBackendBuilder()
+ .withName(RESOURCE_NAME)
+ .withNewPort()
+ .withName("http")
+ .endPort()
+ .build())
+ .build();
+
+ var path = new HTTPIngressPathBuilder()
+ .withPath(ingressPath(tenant))
+ .withPathType("Prefix")
+ .withBackend(backend)
+ .build();
+
+ var rule = new IngressRuleBuilder()
+ .withHost(config.host())
+ .withNewHttp()
+ .withPaths(path)
+ .endHttp()
+ .build();
+
+ return new IngressBuilder()
+ .withNewMetadata()
+ .withName(RESOURCE_NAME)
+ .withNamespace(TenantReconciler.namespaceFor(tenant))
+ .withLabels(labels)
+ .withOwnerReferences(owner)
+ .endMetadata()
+ .withNewSpec()
+ .withIngressClassName(config.ingressClassName())
+ .withRules(rule)
+ .endSpec()
+ .build();
+ }
+
+ private static List env(Tenant tenant, TenantUiConfig config) {
+ return List.of(
+ literal("NUXT_APP_BASE_URL", basePath(tenant)),
+ literal("NUXT_PUBLIC_API_BASE_URL", config.apiBaseUrl()),
+ literal("NUXT_PUBLIC_OIDC_ISSUER", config.oidcIssuer()),
+ literal("NUXT_PUBLIC_OIDC_CLIENT_ID", config.oidcClientId()),
+ literal("NUXT_PUBLIC_OIDC_SCOPE", config.oidcScope()));
+ }
+
+ private static EnvVar literal(String name, String value) {
+ return new EnvVarBuilder().withName(name).withValue(value).build();
+ }
+
+ private static ContainerPort containerPort() {
+ return new ContainerPortBuilder()
+ .withName("http")
+ .withContainerPort(CONTAINER_PORT)
+ .build();
+ }
+
+ /**
+ * Probes hit the tenant's own base path, not {@code /} -- with {@code NUXT_APP_BASE_URL} set,
+ * the bare root 404s and a probe there would restart a perfectly healthy pod forever.
+ */
+ private static Probe probe(Tenant tenant) {
+ return new ProbeBuilder()
+ .withNewHttpGet()
+ .withPath(basePath(tenant))
+ .withNewPort(CONTAINER_PORT)
+ .endHttpGet()
+ .withInitialDelaySeconds(5)
+ .withPeriodSeconds(10)
+ .build();
+ }
+
+ private static ResourceRequirements resources() {
+ return new ResourceRequirementsBuilder()
+ .withRequests(Map.of("cpu", new Quantity(CPU_REQUEST), "memory", new Quantity(MEMORY_REQUEST)))
+ .withLimits(Map.of("memory", new Quantity(MEMORY_LIMIT)))
+ .build();
+ }
+}
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `./gradlew :operator:test --tests '*TenantUiResourceBuilderTest*'`
+Expected: PASS, 9 tests.
+
+- [ ] **Step 5: Format and commit**
+
+```bash
+./gradlew :operator:spotlessApply
+git add operator/src/main/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilder.java \
+ operator/src/test/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilderTest.java
+git commit --no-gpg-sign -m "feat(operator): build the per-tenant application instance manifests"
+```
+
+---
+
+### Task 3: `Tenant.status.redirectUris`
+
+**Files:**
+
+- Modify: `operator/src/main/java/net/onelitefeather/apus/operator/api/TenantStatus.java`
+- Test (modify): `operator/src/test/java/net/onelitefeather/apus/operator/api/ApusResourceTest.java`
+
+**Interfaces:**
+
+- Consumes: nothing.
+- Produces: `TenantStatus.getRedirectUris()` / `setRedirectUris(List)`, defaulting to an empty list.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `ApusResourceTest`:
+
+```java
+ @Test
+ void tenantStatusStartsWithNoRedirectUris() {
+ assertTrue(new TenantStatus().getRedirectUris().isEmpty());
+ }
+
+ @Test
+ void tenantStatusAbsorbsNullRedirectUris() {
+ TenantStatus status = new TenantStatus();
+
+ status.setRedirectUris(null);
+
+ assertTrue(status.getRedirectUris().isEmpty());
+ }
+```
+
+Add whatever imports the file is missing (`TenantStatus`, `assertTrue`) — check the file first, it may already import the package.
+
+- [ ] **Step 2: Run it**
+
+Run: `./gradlew :operator:test --tests '*ApusResourceTest*'`
+Expected: FAIL — `getRedirectUris()` does not exist.
+
+- [ ] **Step 3: Implement**
+
+In `TenantStatus`, beside `pushTokenSecret`:
+
+```java
+ private List redirectUris = new ArrayList<>();
+
+ /**
+ * The redirect URIs the identity provider must have registered for this tenant's own
+ * application instance, or empty when no instance is provisioned. Reported here because the
+ * operator cannot register them itself -- that needs Microsoft Graph application permissions
+ * on the app registration -- and because a missing registration fails at sign-in with {@code
+ * AADSTS50011} from the broker, leaving nothing at all in this cluster's logs to find.
+ */
+ public List getRedirectUris() {
+ return redirectUris;
+ }
+
+ public void setRedirectUris(List redirectUris) {
+ this.redirectUris = redirectUris == null ? new ArrayList<>() : redirectUris;
+ }
+```
+
+- [ ] **Step 4: Run the tests, then regenerate the CRDs**
+
+```bash
+./gradlew :operator:test --tests '*ApusResourceTest*'
+./gradlew :operator:generateCrds
+```
+
+Then sync the regenerated CRD into the chart the same way the policy change did, and confirm the diff touches only `tenants.*.yaml` and only adds `redirectUris`:
+
+```bash
+git diff --stat deploy/charts/apus-operator/templates/crds.yaml
+```
+
+- [ ] **Step 5: Format and commit**
+
+```bash
+./gradlew :operator:spotlessApply
+git add -A operator deploy/charts/apus-operator
+git commit --no-gpg-sign -m "feat(operator): report the redirect URIs a tenant instance needs"
+```
+
+---
+
+### Task 4: `TenantReconciler` provisions the instance
+
+**Files:**
+
+- Modify: `operator/src/main/java/net/onelitefeather/apus/operator/tenant/TenantReconciler.java`
+- Test (modify): `operator/src/test/java/net/onelitefeather/apus/operator/tenant/TenantReconcilerTest.java`
+
+**Interfaces:**
+
+- Consumes: `TenantUiResourceBuilder` (Task 2), `TenantUiConfig` (Task 1), `TenantStatus.setRedirectUris` (Task 3).
+- Produces: no new public API — behaviour only.
+
+- [ ] **Step 1: Read the existing test to match its fixtures**
+
+`TenantReconcilerTest` already sets up a mock server, an `OperatorConfig` and a `Tenant`. Read it before writing anything; reuse its helpers rather than inventing new ones, and note how it constructs `OperatorConfig` — Task 1 added a component, so those call sites need the new argument.
+
+- [ ] **Step 2: Write the failing tests**
+
+```java
+ @Test
+ void provisionsNoApplicationInstanceWhenNoHostIsConfigured() {
+ // The default: a platform that has not opted in gets no per-tenant instance at all.
+ reconciler.reconcile(tenant, context);
+
+ assertNull(client.apps()
+ .deployments()
+ .inNamespace("bluemap-acme")
+ .withName(TenantUiResourceBuilder.RESOURCE_NAME)
+ .get());
+ assertNull(client.services()
+ .inNamespace("bluemap-acme")
+ .withName(TenantUiResourceBuilder.RESOURCE_NAME)
+ .get());
+ assertNull(client.network()
+ .v1()
+ .ingresses()
+ .inNamespace("bluemap-acme")
+ .withName(TenantUiResourceBuilder.RESOURCE_NAME)
+ .get());
+ assertTrue(tenant.getStatus().getRedirectUris().isEmpty());
+ }
+
+ @Test
+ void provisionsTheApplicationInstanceOnceAHostIsConfigured() {
+ TenantReconciler withUi = new TenantReconciler(client, configWithTenantUi());
+
+ withUi.reconcile(tenant, context);
+
+ var deployment = client.apps()
+ .deployments()
+ .inNamespace("bluemap-acme")
+ .withName(TenantUiResourceBuilder.RESOURCE_NAME)
+ .get();
+ assertNotNull(deployment);
+ assertNotNull(client.services()
+ .inNamespace("bluemap-acme")
+ .withName(TenantUiResourceBuilder.RESOURCE_NAME)
+ .get());
+ assertNotNull(client.network()
+ .v1()
+ .ingresses()
+ .inNamespace("bluemap-acme")
+ .withName(TenantUiResourceBuilder.RESOURCE_NAME)
+ .get());
+ assertEquals(
+ List.of(
+ "https://apus.example.dev/t/acme/auth/callback",
+ "https://apus.example.dev/t/acme/auth/silent-renew"),
+ tenant.getStatus().getRedirectUris());
+ }
+
+ @Test
+ void reconcilingTwiceLeavesOneApplicationInstance() {
+ TenantReconciler withUi = new TenantReconciler(client, configWithTenantUi());
+
+ withUi.reconcile(tenant, context);
+ withUi.reconcile(tenant, context);
+
+ assertEquals(
+ 1,
+ client.apps()
+ .deployments()
+ .inNamespace("bluemap-acme")
+ .list()
+ .getItems()
+ .size());
+ }
+```
+
+with a helper beside the existing fixtures:
+
+```java
+ private static OperatorConfig configWithTenantUi() {
+ // Copy every other component from the test's existing config; only tenantUi differs.
+ return new OperatorConfig(
+ /* … the same values the existing fixture uses … */
+ new TenantUiConfig(
+ "apus.example.dev",
+ "apus/ui:1.2.3",
+ "cloudflare-tunnel",
+ "https://apus.example.dev",
+ "https://issuer.example/v2.0",
+ "client-id",
+ "api://client-id/access_as_user openid"));
+ }
+```
+
+- [ ] **Step 3: Run them to make sure they fail**
+
+Run: `./gradlew :operator:test --tests '*TenantReconcilerTest*'`
+Expected: the two "provisions"/"twice" tests FAIL (no Deployment created); the "no host" test PASSES already, which is correct — it pins the default and must stay green throughout.
+
+- [ ] **Step 4: Implement**
+
+In `provisionNamespace`, after the limit range, add:
+
+```java
+ provisionApplicationInstance(tenant, tenantName, tenantUid, ownerReference);
+```
+
+and the method itself:
+
+```java
+ /**
+ * Creates (or updates) this tenant's own instance of the tenant application, served at
+ * {@code https:///t//}. Skipped entirely -- and this is the default -- when no
+ * host is configured: an instance with no host would have nothing to serve it.
+ *
+ * The three objects live in the tenant's own namespace, which is where the Ingress has to
+ * be anyway: an Ingress may only reference a Service in its own namespace.
+ */
+ private void provisionApplicationInstance(
+ Tenant tenant, String tenantName, String tenantUid, OwnerReference ownerReference) {
+ TenantUiConfig tenantUi = config.tenantUi();
+ if (!tenantUi.enabled()) {
+ tenant.getStatus().setRedirectUris(List.of());
+ return;
+ }
+
+ String namespace = namespaceFor(tenant);
+ Map labels = tenantUiLabels(tenantName, tenantUid);
+
+ client.apps()
+ .deployments()
+ .inNamespace(namespace)
+ .resource(TenantUiResourceBuilder.deployment(tenant, tenantUi, labels, ownerReference))
+ .createOr(NonDeletingOperation::update);
+
+ client.services()
+ .inNamespace(namespace)
+ .resource(TenantUiResourceBuilder.service(tenant, labels, ownerReference))
+ .createOr(NonDeletingOperation::update);
+
+ client.network()
+ .v1()
+ .ingresses()
+ .inNamespace(namespace)
+ .resource(TenantUiResourceBuilder.ingress(tenant, tenantUi, labels, ownerReference))
+ .createOr(NonDeletingOperation::update);
+
+ tenant.getStatus().setRedirectUris(TenantUiResourceBuilder.redirectUris(tenant, tenantUi));
+ }
+
+ /**
+ * The application instance's labels: the standard tenant-ownership set, but named for the
+ * component rather than the tenant, because these labels are also the Deployment's selector
+ * and the Service's -- two workloads in one namespace sharing a selector would each take the
+ * other's pods.
+ */
+ private static Map tenantUiLabels(String tenantName, String tenantUid) {
+ Map labels = Labels.standard("tenant-ui", tenantName);
+ labels.put(Labels.TENANT, tenantName);
+ if (tenantUid != null && !tenantUid.isBlank()) {
+ labels.put(Labels.TENANT_UID, tenantUid);
+ }
+ return labels;
+ }
+```
+
+Add `import java.util.List;` and `import net.onelitefeather.apus.operator.TenantUiConfig;`.
+
+Extend the class Javadoc with a paragraph on the per-tenant instance, matching the style of the existing "Push-token Secret" and "Rook not (yet) installed" paragraphs.
+
+- [ ] **Step 5: Run the tests**
+
+Run: `./gradlew :operator:test --tests '*TenantReconcilerTest*'`
+Expected: PASS, including the pre-existing tests.
+
+- [ ] **Step 6: Format and commit**
+
+```bash
+./gradlew :operator:spotlessApply
+git add operator/src/main/java/net/onelitefeather/apus/operator/tenant/TenantReconciler.java \
+ operator/src/test/java/net/onelitefeather/apus/operator/tenant/TenantReconcilerTest.java
+git commit --no-gpg-sign -m "feat(operator): provision an application instance per tenant"
+```
+
+---
+
+### Task 5: Operator RBAC and chart
+
+**Files:**
+
+- Modify: `deploy/charts/apus-operator/values.yaml`
+- Modify: `deploy/charts/apus-operator/templates/deployment.yaml`
+- Modify: `deploy/charts/apus-operator/templates/rbac.yaml`
+- Modify: `deploy/charts/apus-operator/templates/NOTES.txt`
+- Modify: `deploy/charts/apus-operator/values.schema.json` (if the chart has one — check)
+
+**Interfaces:**
+
+- Consumes: the `APUS_TENANT_UI_*` variable names from Task 1.
+- Produces: the `tenantUi` value block.
+
+- [ ] **Step 1: Confirm the RBAC the operator already has**
+
+```bash
+grep -n -A4 'deployments\|ingresses\|services' deploy/charts/apus-operator/templates/rbac.yaml
+```
+
+The operator already creates Deployments, Services and Ingresses for `BlueMapHosting`, so the verbs are expected to be there. **If any is missing, add it** — this is the one failure mode that produces a clean-looking reconcile and no resources.
+
+- [ ] **Step 2: Add the values**
+
+In `values.yaml`, after the `bundles` block:
+
+```yaml
+# One instance of the tenant application per tenant, served at https:///t//.
+# The operator creates a Deployment, a Service and an Ingress in each tenant's own namespace.
+#
+# Off by default: an empty host disables the feature entirely. An instance with no host would
+# have nothing to serve it, and a Deployment nobody can reach costs a pod per tenant.
+#
+# Registering the two redirect URIs each instance needs is a manual step -- the operator has no
+# permission on the app registration. They are reported on Tenant.status.redirectUris.
+tenantUi:
+ host: ""
+ image:
+ repository: harbor.onelitefeather.dev/apus/ui
+ tag: ""
+ # Must match the platform ingress's class: both serve paths on the same host.
+ ingressClassName: nginx
+ # The origin only, with no /api suffix -- the typed client already asks for /api paths.
+ apiBaseUrl: ""
+ oidc:
+ issuer: ""
+ clientId: ""
+ scope: ""
+```
+
+- [ ] **Step 3: Render them into the operator's environment**
+
+In `templates/deployment.yaml`, after `APUS_BUNDLE_CREDENTIALS_SECRET`:
+
+```yaml
+ - name: APUS_TENANT_UI_HOST
+ value: {{ .Values.tenantUi.host | quote }}
+ - name: APUS_TENANT_UI_IMAGE
+ value: {{ include "apus-operator.image" (dict "image" .Values.tenantUi.image "ctx" .) | quote }}
+ - name: APUS_TENANT_UI_INGRESS_CLASS
+ value: {{ .Values.tenantUi.ingressClassName | quote }}
+ - name: APUS_TENANT_UI_API_BASE_URL
+ value: {{ .Values.tenantUi.apiBaseUrl | quote }}
+ - name: APUS_TENANT_UI_OIDC_ISSUER
+ value: {{ .Values.tenantUi.oidc.issuer | quote }}
+ - name: APUS_TENANT_UI_OIDC_CLIENT_ID
+ value: {{ .Values.tenantUi.oidc.clientId | quote }}
+ - name: APUS_TENANT_UI_OIDC_SCOPE
+ value: {{ .Values.tenantUi.oidc.scope | quote }}
+```
+
+- [ ] **Step 4: Say the manual step out loud in NOTES.txt**
+
+Append a section that prints only when `tenantUi.host` is set, naming the two URIs with `` as a placeholder and pointing at `kubectl get tenant -o jsonpath='{.status.redirectUris}'` for the real ones.
+
+- [ ] **Step 5: Verify the chart renders**
+
+```bash
+helm template apus deploy/charts/apus-operator | grep -A1 APUS_TENANT_UI
+helm template apus deploy/charts/apus-operator --set tenantUi.host=apus.example.dev | grep -A1 APUS_TENANT_UI_HOST
+helm lint deploy/charts/apus-operator
+```
+
+Expected: the first shows an empty host (feature off), the second shows `apus.example.dev`, lint passes.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add deploy/charts/apus-operator
+git commit --no-gpg-sign -m "feat(chart): expose the per-tenant application instance settings"
+```
+
+---
+
+### Task 6: The console shows the redirect URIs
+
+**Files:**
+
+- Modify: the console's tenant view (find it: `rg -l 'tenant' ui/apps/console/app/pages`)
+- Modify: whichever API response type carries a tenant (find it: `rg -n 'redirectUris|pushTokenSecret' api/src/main/java`)
+- Test: beside the component being changed, matching the existing component-test style
+
+**Interfaces:**
+
+- Consumes: `Tenant.status.redirectUris` from Task 3.
+- Produces: no new API.
+
+- [ ] **Step 1: Find out whether the API already exposes tenant status**
+
+```bash
+rg -n 'class TenantView|record TenantView|status' api/src/main/java/net/onelitefeather/apus/api/rest/tenant/
+```
+
+If the API's tenant representation carries no status fields at all, **add `redirectUris` to it** — a read-only list, no new endpoint. If it already carries status, extend it.
+
+- [ ] **Step 2: Write the failing component test**
+
+A test asserting the tenant view renders both URIs and a copy control, and renders nothing at all when the list is empty (a tenant with no instance must not show an empty "redirect URIs" box).
+
+- [ ] **Step 3: Run it, implement, run it again**
+
+```bash
+cd ui && pnpm test
+```
+
+- [ ] **Step 4: Lint, typecheck, commit**
+
+```bash
+cd ui && pnpm lint && pnpm typecheck && pnpm test
+git add ui api
+git commit --no-gpg-sign -m "feat(console): show the redirect URIs a tenant instance needs"
+```
+
+---
+
+### Task 7: Full verification
+
+- [ ] **Step 1: The whole build**
+
+```bash
+./gradlew :operator:test :api:test spotlessCheck
+cd ui && pnpm lint && pnpm typecheck && pnpm test
+```
+
+- [ ] **Step 2: Prove it against a real API server, not a mock**
+
+The mock server does not enforce the `ResourceQuota`, which is the single most likely thing to be wrong. `OperatorIntegrationTest`/`K3sCrdSupport` already stand up k3s; add a case there that reconciles a tenant with `tenantUi` enabled and asserts a **pod** appears, not merely a Deployment. If that turns out not to fit the existing harness, say so in the PR rather than quietly dropping it.
+
+- [ ] **Step 3: Commit and open the PR**
+
+Describe the four findings from §0 of the spec, and state plainly that per-tenant Entra registration remains manual and why.
+
+## Self-Review
+
+**Spec coverage:** §1 address → Tasks 2, 4. §2 resources → Tasks 2, 4. §3 configuration → Tasks 1, 5. §4 Entra step → Tasks 2 (`redirectUris`), 3 (status), 5 (NOTES), 6 (console). §5 no UI change → nothing to do, correct. §6 tests → Tasks 2, 4, 7.
+
+**Type consistency:** `TenantUiConfig`'s seven components are spelled identically in Tasks 1, 2 and 4. `RESOURCE_NAME` is used, never re-spelled as a literal, after Task 2 defines it. `basePath` keeps the trailing slash and `ingressPath` drops it in every use.
+
+**Known soft spots, stated rather than hidden:** Task 6 begins with a search because the console's tenant view and the API's tenant representation have not been read yet — the task says what to do in either case rather than pretending to know. Task 7 Step 2 may not fit the existing k3s harness; it says to report that instead of silently skipping it.
diff --git a/docs/superpowers/specs/2026-08-16-per-tenant-app-instance-design.md b/docs/superpowers/specs/2026-08-16-per-tenant-app-instance-design.md
new file mode 100644
index 0000000..d51c9e2
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-16-per-tenant-app-instance-design.md
@@ -0,0 +1,189 @@
+# Apus — An Application Instance per Tenant: Design
+
+**As of:** 2026-08-16
+**Status:** Draft for approval
+
+Every tenant gets its own deployed instance of the tenant application, served under its own path
+on the existing host. This is the second of four subsystems the larger goal decomposes into; teams
+and users through Entra, and impersonation, each get their own spec and are out of scope here.
+
+## 0. What the investigation settled first
+
+Four findings shaped everything below. Each was established by reading the source or the cluster,
+not by reasoning about how these things usually work — and two of them contradicted the first
+draft of this design.
+
+**One image can serve any number of tenants.** `NUXT_APP_BASE_URL` is honoured by the built Nitro
+server at runtime: started with `/t/acme/`, the server reports that prefix, serves its shell and
+deep links under it, emits assets at `/t/acme/_nuxt/…`, and exposes `baseURL: "/t/acme/"` to the
+client runtime config. Verified against the actual built image. This is what makes the subsystem
+small: an instance per tenant is the same image with one more environment variable, not a
+per-tenant build.
+
+**Microsoft Entra allows wildcard redirect URIs here — and using one would break the login.**
+Wildcards are permitted for registrations that sign in only work or school accounts, which is ours
+(`signInAudience: AzureADMyOrg`). But Microsoft's documentation states that *"when a configured
+wildcard URI matches a redirect URI, query strings and fragments in the redirect URI are
+stripped"*. The authorization-code flow returns `code` and `state` in exactly that query string,
+so a wildcard would strip the response the callback needs. **Every tenant address therefore needs
+two explicit redirect URIs registered.** The 256-URI registration limit is the real ceiling on
+tenants: roughly 128.
+
+An earlier attempt to settle this empirically — pointing the authorize endpoint at a
+wildcard-matched host — appeared to succeed. The control case, an entirely unrelated host,
+appeared to succeed too: Entra renders its sign-in page before validating the redirect URI at all.
+The test proved nothing, and the documentation is what settles it.
+
+**Path ordering is safe, but not for the reason the platform chart gives.** This cluster's
+ingress class is `cloudflare-tunnel` (`strrl.dev/cloudflare-tunnel-ingress-controller`), not
+nginx. That controller flattens *every* Ingress object in the cluster into one list of Cloudflare
+tunnel rules and sorts it globally — non-wildcard hosts first, then by hostname, then **by path
+length descending** — before appending a `http_status:404` catch-all
+(`pkg/cloudflare-controller/tunnel-client.go`, `sortIngressRules`). So `/t/acme` (7 characters)
+sorts ahead of `/` (1) automatically, no matter which Ingress object declared it or in what order.
+The platform chart's ingress template carries a comment warning that path order within the rule
+list is load-bearing; that is true of nginx and remains good practice, but it is the controller's
+global sort that actually makes a *separate* per-tenant Ingress work here.
+
+**A pod in a tenant namespace must declare resource requests or it is never created.**
+`TenantReconciler` puts a `ResourceQuota` on every tenant namespace constraining `requests.cpu`
+and `requests.memory`, and the `LimitRange` beside it has no spec at all — confirmed on the live
+cluster, where `bluemap-onelitefeather-dev`'s limit range reads `spec={"limits":null}`. A quota on
+a compute resource makes that request mandatory for every pod, and an empty limit range supplies
+no default to fall back on. A Deployment without explicit requests would be created happily and
+then never produce a pod. The builder therefore sets requests unconditionally.
+
+Proven rather than argued, with `kubectl apply --dry-run=server` against that namespace — server
+dry-run runs the admission plugins without creating anything. Without requests the API server
+answers `pods "…" is forbidden: failed quota: apus-tenant: must specify requests.cpu for: ui;
+requests.memory for: ui`. With them, admitted.
+
+**And a `PodSecurity "restricted"` warning came back with it, which caught a real defect.** The
+same dry-run warned that the probe pod violated `restricted`. The platform chart hardens its own
+`ui` Deployment — `runAsNonRoot`, uid 65532, `RuntimeDefault` seccomp, no privilege escalation,
+read-only root, all capabilities dropped — and the first version of the builder here applied none
+of it to the *same image*. It is only a warning on this cluster today, so nothing would have
+failed; it would simply have been the same software running less restricted because a controller
+created it instead of Helm, and it would turn into a hard rejection the moment a platform sets
+tenant namespaces to enforce. The builder now applies the identical settings, and the hardened
+pod passes the same dry-run with no warning at all.
+
+## 1. The address
+
+`https:///t//`, on the same host the platform already serves.
+
+Chosen over a subdomain per tenant because it costs nothing operationally that the cluster does
+not already do: no wildcard DNS record, no wildcard certificate, and — decisively — no CORS.
+The `api` module deliberately configures none, so a tenant application on a different origin
+could not call it without changing the API (see `ui/README.md`, "Why the console is same-origin").
+The path scheme is also the one already proven here: the console has been served under `/console`
+since 0.7.0.
+
+The cost is that a tenant cannot have a domain of its own. That is a real limitation and the
+reason the subdomain variant is written down here rather than dismissed — it becomes available if
+and when the API grows a CORS configuration, and nothing in this design would have to be undone
+to move.
+
+## 2. What the operator creates
+
+`TenantReconciler` already provisions the namespace `bluemap-`, a compute quota, a limit
+range, a push-token Secret and a Ceph object-store user. It gains a fifth responsibility, guarded
+so that a platform which does not want per-tenant instances is unaffected:
+
+| Resource | Name | In | Notes |
+| --- | --- | --- | --- |
+| `Deployment` | `apus-tenant-ui` | `bluemap-` | one replica, the tenant-UI image, `NUXT_APP_BASE_URL=/t//`, explicit resource requests |
+| `Service` | `apus-tenant-ui` | `bluemap-` | ClusterIP, port `http` → container 8080 |
+| `Ingress` | `apus-tenant-ui` | `bluemap-` | one rule: `` + path `/t/`, `pathType: Prefix` |
+
+All three carry the tenant name/UID labels the reconciler already stamps and the same owner
+reference its `ResourceQuota` and `LimitRange` already use — a namespaced dependent of the
+cluster-scoped `Tenant`, which is an ownership Kubernetes permits and this reconciler has relied
+on since it was written.
+
+**The Ingress must live in the tenant's namespace, so it must be a per-tenant object.** This is
+not a preference: an `Ingress` may only reference a `Service` in its own namespace, and each
+tenant's Service is in `bluemap-`. A single operator-owned Ingress listing every tenant's
+path is therefore not available at any price. The per-tenant object is also the better outcome —
+it is garbage-collected with the tenant, and the platform chart's ingress stays a static file
+rather than something a controller writes to.
+
+**No ingress annotations are set.** The tunnel controller defaults `backend-protocol` to `http`
+(`well_known_annotations.go`), which is exactly what the platform ingress spells out explicitly,
+and TLS terminates at Cloudflare's edge so there is no `tls` section and no cert-manager
+annotation to add. A deployment onto an ingress class that needs annotations is out of scope; the
+class name itself is configurable.
+
+## 3. Configuration
+
+`OperatorConfig` is built entirely from environment variables, so these follow that shape rather
+than introducing a second mechanism. The chart renders them from a `tenantUi` value block.
+
+| Env | Chart key | Meaning |
+| --- | --- | --- |
+| `APUS_TENANT_UI_HOST` | `tenantUi.host` | The host the paths hang off. **Empty disables the whole feature** |
+| `APUS_TENANT_UI_IMAGE` | `tenantUi.image` | The tenant application image to run |
+| `APUS_TENANT_UI_INGRESS_CLASS` | `tenantUi.ingressClassName` | Matches whatever the platform ingress uses |
+| `APUS_TENANT_UI_API_BASE_URL` | `tenantUi.apiBaseUrl` | → `NUXT_PUBLIC_API_BASE_URL` |
+| `APUS_TENANT_UI_OIDC_ISSUER` | `tenantUi.oidc.issuer` | → `NUXT_PUBLIC_OIDC_ISSUER` |
+| `APUS_TENANT_UI_OIDC_CLIENT_ID` | `tenantUi.oidc.clientId` | → `NUXT_PUBLIC_OIDC_CLIENT_ID` |
+| `APUS_TENANT_UI_OIDC_SCOPE` | `tenantUi.oidc.scope` | → `NUXT_PUBLIC_OIDC_SCOPE` |
+
+The four `NUXT_PUBLIC_*` values are modelled one by one rather than as a free-form map, because a
+map would have to be serialised through a single environment variable and would lose the schema,
+the documentation and the ability to test each value. They are identical for every tenant — same
+API, same issuer, same OIDC client — and only `NUXT_APP_BASE_URL` differs, which the operator
+computes. None is a secret; every one of them reaches the served HTML by design.
+
+**An empty host means the feature is off**, and off is the default. A tenant instance with no host
+would have nothing to serve it, and an operator that created Deployments nobody could reach would
+burn a pod per tenant for nothing.
+
+## 4. The Entra step, which cannot be automated here
+
+Each tenant instance needs two redirect URIs registered before anyone can sign in to it:
+
+```text
+https:///t//auth/callback
+https:///t//auth/silent-renew
+```
+
+The operator cannot add them. Doing so would require Microsoft Graph application permissions on
+the app registration — a security grant belonging to a different subsystem (teams and users) and a
+decision that has not been made. Until it is, creating a tenant is a two-step operation: apply the
+`Tenant`, then register its two URIs.
+
+**A missing registration fails at sign-in, not at deploy time**, with `AADSTS50011` from the
+broker and nothing in this cluster's logs. Everything about how this is surfaced follows from
+that: the operator writes both URIs into `Tenant.status.redirectUris`, so `kubectl get tenant -o
+yaml` answers the question, and the console's tenant view shows them with a copy action — telling
+whoever just created a tenant what remains, at the moment they would otherwise walk away.
+
+## 5. What the tenant application needs
+
+Nothing. `buildOidcRedirectUris(origin, baseURL)` already derives the callback from the runtime
+base path, and its unit tests already cover a nested prefix (`/admin/console/`) for exactly this
+reason. The API base URL stays the origin, unchanged. No code in `apps/app` is aware that it is
+one instance among several.
+
+## 6. Tests
+
+| What | How |
+| --- | --- |
+| Resource shape | Unit tests over a pure `TenantUiResourceBuilder`: base URL env is `/t//`, ingress path is `/t/`, host and image come from config, labels and owner reference match the reconciler's |
+| Resource requests | Asserted explicitly, with the quota finding named in the test — the one mistake here produces a Deployment that looks healthy and has no pods |
+| Hardening | Asserted against the platform chart's values for the same image, so the two cannot drift apart silently |
+| Admission, for real | A k3s integration test reconciles a tenant with the feature on and waits for a **Pod**, not a Deployment: only a cluster with quota admission can fail that assertion. It sits with the module's other `*IntegrationTest` classes, which need Docker and run neither in `check` nor in CI |
+| Feature off | With no host configured, reconciling creates no Deployment, no Service and no Ingress, and sets no `redirectUris` — the default must be inert |
+| Feature on | Reconciling creates all three, and `status.redirectUris` carries exactly the two URIs from §4 |
+| Idempotence | Reconciling twice leaves one Deployment, matching how the namespace and quota paths already behave |
+| Ownership | The owner reference is present on all three so Kubernetes garbage-collects them; asserted on the built objects |
+
+## 7. Non-goals
+
+- **No per-tenant branding or configuration of the instance.** Every instance runs the same image
+ with the same public configuration. Per-tenant appearance would belong with the policy work.
+- **No automatic Entra registration.** §4.
+- **No subdomain or custom-domain addressing.** §1 — available later without migrating anything.
+- **No change to the API.** The console gains one read-only display of the two redirect URIs;
+ nothing else moves.
diff --git a/operator/src/main/java/net/onelitefeather/apus/operator/OperatorConfig.java b/operator/src/main/java/net/onelitefeather/apus/operator/OperatorConfig.java
index 99fdea9..1a48e6d 100644
--- a/operator/src/main/java/net/onelitefeather/apus/operator/OperatorConfig.java
+++ b/operator/src/main/java/net/onelitefeather/apus/operator/OperatorConfig.java
@@ -46,6 +46,10 @@
* how {@code RenderJobBuilder} is handed a bucket secret name already scoped to the map's
* namespace -- carrying the bundle bucket's {@code AWS_ACCESS_KEY_ID}/{@code
* AWS_SECRET_ACCESS_KEY}
+ * @param tenantUi settings for the per-tenant instance of the tenant application, provisioned by
+ * {@code TenantReconciler}. Grouped into its own record rather than flattened into seven more
+ * components here, because they belong to one optional feature that is off unless a host is
+ * configured -- see {@link TenantUiConfig}
*/
public record OperatorConfig(
String rookNamespace,
@@ -57,7 +61,8 @@ public record OperatorConfig(
String bundleBucket,
String bundleS3Endpoint,
String bundleS3Region,
- String bundleCredentialsSecretName) {
+ String bundleCredentialsSecretName,
+ TenantUiConfig tenantUi) {
private static final String DEFAULT_ROOK_NAMESPACE = "rook-ceph-fr01";
private static final String DEFAULT_CEPH_OBJECT_STORE = "feather-s3";
@@ -82,7 +87,8 @@ public static OperatorConfig defaults() {
DEFAULT_BUNDLE_BUCKET,
DEFAULT_BUNDLE_S3_ENDPOINT,
DEFAULT_BUNDLE_S3_REGION,
- DEFAULT_BUNDLE_CREDENTIALS_SECRET);
+ DEFAULT_BUNDLE_CREDENTIALS_SECRET,
+ TenantUiConfig.disabled());
}
/**
@@ -95,7 +101,8 @@ public static OperatorConfig defaults() {
* Recognised variables: {@code APUS_ROOK_NAMESPACE}, {@code APUS_CEPH_OBJECT_STORE},
* {@code APUS_BUCKET_STORAGE_CLASS}, {@code APUS_RUNNER_IMAGE}, {@code APUS_INGEST_IMAGE},
* {@code APUS_HOSTING_IMAGE}, {@code APUS_BUNDLE_BUCKET}, {@code APUS_BUNDLE_S3_ENDPOINT},
- * {@code APUS_BUNDLE_S3_REGION}, {@code APUS_BUNDLE_CREDENTIALS_SECRET}.
+ * {@code APUS_BUNDLE_S3_REGION}, {@code APUS_BUNDLE_CREDENTIALS_SECRET}, plus the {@code
+ * APUS_TENANT_UI_*} set {@link TenantUiConfig#fromEnvironment(Function)} reads.
*/
public static OperatorConfig fromEnvironment(Function env) {
return new OperatorConfig(
@@ -108,7 +115,8 @@ public static OperatorConfig fromEnvironment(Function env) {
valueOrDefault(env.apply("APUS_BUNDLE_BUCKET"), DEFAULT_BUNDLE_BUCKET),
valueOrDefault(env.apply("APUS_BUNDLE_S3_ENDPOINT"), DEFAULT_BUNDLE_S3_ENDPOINT),
valueOrDefault(env.apply("APUS_BUNDLE_S3_REGION"), DEFAULT_BUNDLE_S3_REGION),
- valueOrDefault(env.apply("APUS_BUNDLE_CREDENTIALS_SECRET"), DEFAULT_BUNDLE_CREDENTIALS_SECRET));
+ valueOrDefault(env.apply("APUS_BUNDLE_CREDENTIALS_SECRET"), DEFAULT_BUNDLE_CREDENTIALS_SECRET),
+ TenantUiConfig.fromEnvironment(env));
}
private static String valueOrDefault(String value, String defaultValue) {
diff --git a/operator/src/main/java/net/onelitefeather/apus/operator/TenantUiConfig.java b/operator/src/main/java/net/onelitefeather/apus/operator/TenantUiConfig.java
new file mode 100644
index 0000000..3856bcd
--- /dev/null
+++ b/operator/src/main/java/net/onelitefeather/apus/operator/TenantUiConfig.java
@@ -0,0 +1,96 @@
+/**
+ * Apus - render and host BlueMap maps on Kubernetes.
+ * Copyright (C) 2026 OneLiteFeather and contributors
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.apus.operator;
+
+import java.util.function.Function;
+
+/**
+ * Settings for the per-tenant application instance {@code TenantReconciler} provisions: which
+ * image to run it from, which host and ingress class to expose it on, and the public runtime
+ * configuration every instance is handed.
+ *
+ *
The four {@code NUXT_PUBLIC_*} values are modelled one by one rather than as a free-form
+ * map: {@link OperatorConfig} is built entirely from environment variables, so a map would have
+ * to be serialised through a single variable and would lose its schema, its documentation and the
+ * ability to test each value on its own. All four are identical for every tenant -- same API,
+ * same issuer, same OIDC client -- and none of them is a secret; every one ends up in the served
+ * HTML by design.
+ *
+ * @param host the host tenant paths hang off. Blank disables the feature entirely, and
+ * blank is the default: an instance with no host would have nothing to serve it, and creating
+ * a Deployment nobody can reach would burn a pod per tenant for nothing
+ * @param image the tenant application image -- the same one the platform chart deploys as its
+ * own {@code ui}, since one image serves any prefix (see {@code
+ * net.onelitefeather.apus.operator.tenant.TenantUiResourceBuilder})
+ * @param ingressClassName the ingress class of the per-tenant {@code Ingress}. Must match the
+ * platform ingress's class: both serve paths on {@link #host}
+ * @param apiBaseUrl becomes {@code NUXT_PUBLIC_API_BASE_URL}. The origin only, with no {@code
+ * /api} suffix -- every method of the typed client already asks for a path beginning with
+ * {@code /api}, so a suffix here produces {@code /api/api/tenants} and a bare 403
+ * @param oidcIssuer becomes {@code NUXT_PUBLIC_OIDC_ISSUER}
+ * @param oidcClientId becomes {@code NUXT_PUBLIC_OIDC_CLIENT_ID}
+ * @param oidcScope becomes {@code NUXT_PUBLIC_OIDC_SCOPE}
+ */
+public record TenantUiConfig(
+ String host,
+ String image,
+ String ingressClassName,
+ String apiBaseUrl,
+ String oidcIssuer,
+ String oidcClientId,
+ String oidcScope) {
+
+ private static final String DEFAULT_IMAGE = "apus/ui:dev";
+ private static final String DEFAULT_INGRESS_CLASS = "nginx";
+
+ /** The feature switched off: no host, so no per-tenant instance is provisioned at all. */
+ public static TenantUiConfig disabled() {
+ return new TenantUiConfig("", DEFAULT_IMAGE, DEFAULT_INGRESS_CLASS, "", "", "", "");
+ }
+
+ /**
+ * Builds the settings from environment variables, mirroring {@link
+ * OperatorConfig#fromEnvironment(Function)} -- including taking a {@code Function} rather than
+ * reading {@link System#getenv()} directly, so tests supply a fake environment instead of
+ * mutating the real one.
+ *
+ *
Recognised variables: {@code APUS_TENANT_UI_HOST}, {@code APUS_TENANT_UI_IMAGE}, {@code
+ * APUS_TENANT_UI_INGRESS_CLASS}, {@code APUS_TENANT_UI_API_BASE_URL}, {@code
+ * APUS_TENANT_UI_OIDC_ISSUER}, {@code APUS_TENANT_UI_OIDC_CLIENT_ID}, {@code
+ * APUS_TENANT_UI_OIDC_SCOPE}.
+ */
+ public static TenantUiConfig fromEnvironment(Function env) {
+ return new TenantUiConfig(
+ valueOrDefault(env.apply("APUS_TENANT_UI_HOST"), ""),
+ valueOrDefault(env.apply("APUS_TENANT_UI_IMAGE"), DEFAULT_IMAGE),
+ valueOrDefault(env.apply("APUS_TENANT_UI_INGRESS_CLASS"), DEFAULT_INGRESS_CLASS),
+ valueOrDefault(env.apply("APUS_TENANT_UI_API_BASE_URL"), ""),
+ valueOrDefault(env.apply("APUS_TENANT_UI_OIDC_ISSUER"), ""),
+ valueOrDefault(env.apply("APUS_TENANT_UI_OIDC_CLIENT_ID"), ""),
+ valueOrDefault(env.apply("APUS_TENANT_UI_OIDC_SCOPE"), ""));
+ }
+
+ /** Whether a per-tenant instance should be provisioned at all -- see {@link #host}. */
+ public boolean enabled() {
+ return host != null && !host.isBlank();
+ }
+
+ private static String valueOrDefault(String value, String defaultValue) {
+ return (value == null || value.isBlank()) ? defaultValue : value;
+ }
+}
diff --git a/operator/src/main/java/net/onelitefeather/apus/operator/api/TenantStatus.java b/operator/src/main/java/net/onelitefeather/apus/operator/api/TenantStatus.java
index 9949ec8..e11df77 100644
--- a/operator/src/main/java/net/onelitefeather/apus/operator/api/TenantStatus.java
+++ b/operator/src/main/java/net/onelitefeather/apus/operator/api/TenantStatus.java
@@ -28,6 +28,7 @@ public class TenantStatus {
private String objectStoreUser;
private Long storageUsedBytes;
private String pushTokenSecret;
+ private List redirectUris = new ArrayList<>();
private List conditions = new ArrayList<>();
public String getNamespace() {
@@ -70,6 +71,27 @@ public void setPushTokenSecret(String pushTokenSecret) {
this.pushTokenSecret = pushTokenSecret;
}
+ /**
+ * The redirect URIs the identity provider must have registered for this tenant's own instance
+ * of the tenant application, or empty when no instance is provisioned.
+ *
+ * Reported here because the operator cannot register them itself -- that needs Microsoft
+ * Graph application permissions on the app registration, a grant nobody has made -- and
+ * because a missing registration fails at sign-in with {@code AADSTS50011} from the broker,
+ * leaving nothing at all in this cluster's logs to find. So {@code kubectl get tenant -o yaml}
+ * answers the question instead, and the console shows the same two lines with a copy action.
+ *
+ *
Nothing here is a secret: these are two public URLs derived from the host and the
+ * tenant's name.
+ */
+ public List getRedirectUris() {
+ return redirectUris;
+ }
+
+ public void setRedirectUris(List redirectUris) {
+ this.redirectUris = redirectUris == null ? new ArrayList<>() : redirectUris;
+ }
+
public List getConditions() {
return conditions;
}
diff --git a/operator/src/main/java/net/onelitefeather/apus/operator/tenant/TenantReconciler.java b/operator/src/main/java/net/onelitefeather/apus/operator/tenant/TenantReconciler.java
index c0233bb..c50c9b6 100644
--- a/operator/src/main/java/net/onelitefeather/apus/operator/tenant/TenantReconciler.java
+++ b/operator/src/main/java/net/onelitefeather/apus/operator/tenant/TenantReconciler.java
@@ -34,9 +34,11 @@
import io.javaoperatorsdk.operator.api.reconciler.UpdateControl;
import io.opentelemetry.api.common.Attributes;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.Objects;
import net.onelitefeather.apus.operator.OperatorConfig;
+import net.onelitefeather.apus.operator.TenantUiConfig;
import net.onelitefeather.apus.operator.api.Conditions;
import net.onelitefeather.apus.operator.api.Labels;
import net.onelitefeather.apus.operator.api.Tenant;
@@ -78,6 +80,15 @@
* Tenant.status} -- only {@link net.onelitefeather.apus.operator.api.TenantStatus#getPushTokenSecret()},
* the Secret's (non-secret, fixed) name, is.
*
+ * Application instance: when a host is configured ({@link
+ * net.onelitefeather.apus.operator.TenantUiConfig}), a tenant also gets its own instance of the
+ * tenant application -- a Deployment, Service and Ingress serving {@code
+ * https:///t//} from the tenant's own namespace, built by {@link
+ * TenantUiResourceBuilder}. The feature is off unless a host is set, because an instance with no
+ * host would have nothing to serve it. The two redirect URIs the identity provider still has to
+ * be told about are reported on {@code status.redirectUris}; the operator cannot register them
+ * itself.
+ *
* Rook not (yet) installed: {@link #reconcile} checks {@link
* io.fabric8.kubernetes.client.Client#supports(Class)} for {@link CephObjectStoreUser} before
* touching it. If Rook's {@code CephObjectStoreUser} CRD is not registered on the cluster, the
@@ -308,6 +319,53 @@ private void provisionNamespace(
.endMetadata()
.build())
.createOr(NonDeletingOperation::update);
+
+ provisionApplicationInstance(tenant, namespace, tenantName, tenantUid, ownerReference);
+ }
+
+ /**
+ * Creates (or updates) this tenant's own instance of the tenant application, served at
+ * {@code https:///t//}: a Deployment, a Service and an Ingress, all three in the
+ * tenant's own namespace, which is where the Ingress has to be anyway -- an Ingress may only
+ * reference a Service in its own namespace.
+ *
+ * Skipped entirely, and this is the default, when no host is configured: an instance with
+ * no host would have nothing to serve it, so creating one would burn a pod per tenant for
+ * nothing. {@code status.redirectUris} is cleared in that case rather than left stale, so a
+ * platform that switches the feature back off does not keep advertising URIs for an instance
+ * that no longer exists.
+ */
+ private void provisionApplicationInstance(
+ Tenant tenant, String namespace, String tenantName, String tenantUid, OwnerReference ownerReference) {
+ TenantUiConfig tenantUi = config.tenantUi();
+ if (!tenantUi.enabled()) {
+ tenant.getStatus().setRedirectUris(List.of());
+ return;
+ }
+
+ Map labels = tenantUiLabels(tenantName, tenantUid);
+
+ client.apps()
+ .deployments()
+ .inNamespace(namespace)
+ .resource(TenantUiResourceBuilder.deployment(tenant, tenantUi, labels, ownerReference))
+ .createOr(NonDeletingOperation::update);
+
+ client.services()
+ .inNamespace(namespace)
+ .resource(TenantUiResourceBuilder.service(tenant, labels, ownerReference))
+ .createOr(NonDeletingOperation::update);
+
+ client.network()
+ .v1()
+ .ingresses()
+ .inNamespace(namespace)
+ .resource(TenantUiResourceBuilder.ingress(tenant, tenantUi, labels, ownerReference))
+ .createOr(NonDeletingOperation::update);
+
+ // Reported, not registered: see TenantUiResourceBuilder#redirectUris for why the operator
+ // cannot add these to the app registration itself.
+ tenant.getStatus().setRedirectUris(TenantUiResourceBuilder.redirectUris(tenant, tenantUi));
}
/**
@@ -384,6 +442,21 @@ private static Map tenantLabels(String tenantName, String tenant
* FabricPushTokenRepository} in the {@code api} module actually queries by, since a raw push
* token carries no namespace of its own to look the Secret up by name directly.
*/
+ /**
+ * The application instance's labels. Deliberately not {@link #tenantLabels}: these
+ * double as the Deployment's pod selector and the Service's, and every other resource here
+ * carries {@code app.kubernetes.io/name: tenant}. Two workloads in one namespace sharing a
+ * selector would each take the other's pods, so this set names the component instead.
+ */
+ private static Map tenantUiLabels(String tenantName, String tenantUid) {
+ Map labels = Labels.standard("tenant-ui", tenantName);
+ labels.put(Labels.TENANT, tenantName);
+ if (tenantUid != null && !tenantUid.isBlank()) {
+ labels.put(Labels.TENANT_UID, tenantUid);
+ }
+ return labels;
+ }
+
private static Map pushTokenLabels(String tenantName, String tenantUid) {
Map labels = new HashMap<>(tenantLabels(tenantName, tenantUid));
labels.put(PushTokenSecrets.LABEL_KEY, PushTokenSecrets.LABEL_VALUE);
diff --git a/operator/src/main/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilder.java b/operator/src/main/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilder.java
new file mode 100644
index 0000000..d98f7e0
--- /dev/null
+++ b/operator/src/main/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilder.java
@@ -0,0 +1,348 @@
+/**
+ * Apus - render and host BlueMap maps on Kubernetes.
+ * Copyright (C) 2026 OneLiteFeather and contributors
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.apus.operator.tenant;
+
+import io.fabric8.kubernetes.api.model.Container;
+import io.fabric8.kubernetes.api.model.ContainerBuilder;
+import io.fabric8.kubernetes.api.model.ContainerPort;
+import io.fabric8.kubernetes.api.model.ContainerPortBuilder;
+import io.fabric8.kubernetes.api.model.EnvVar;
+import io.fabric8.kubernetes.api.model.EnvVarBuilder;
+import io.fabric8.kubernetes.api.model.OwnerReference;
+import io.fabric8.kubernetes.api.model.PodSecurityContext;
+import io.fabric8.kubernetes.api.model.PodSecurityContextBuilder;
+import io.fabric8.kubernetes.api.model.Probe;
+import io.fabric8.kubernetes.api.model.ProbeBuilder;
+import io.fabric8.kubernetes.api.model.Quantity;
+import io.fabric8.kubernetes.api.model.ResourceRequirements;
+import io.fabric8.kubernetes.api.model.ResourceRequirementsBuilder;
+import io.fabric8.kubernetes.api.model.SecurityContext;
+import io.fabric8.kubernetes.api.model.SecurityContextBuilder;
+import io.fabric8.kubernetes.api.model.Service;
+import io.fabric8.kubernetes.api.model.ServiceBuilder;
+import io.fabric8.kubernetes.api.model.ServicePort;
+import io.fabric8.kubernetes.api.model.ServicePortBuilder;
+import io.fabric8.kubernetes.api.model.apps.Deployment;
+import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder;
+import io.fabric8.kubernetes.api.model.networking.v1.HTTPIngressPathBuilder;
+import io.fabric8.kubernetes.api.model.networking.v1.Ingress;
+import io.fabric8.kubernetes.api.model.networking.v1.IngressBackendBuilder;
+import io.fabric8.kubernetes.api.model.networking.v1.IngressBuilder;
+import io.fabric8.kubernetes.api.model.networking.v1.IngressRuleBuilder;
+import io.fabric8.kubernetes.api.model.networking.v1.IngressServiceBackendBuilder;
+import java.util.List;
+import java.util.Map;
+import net.onelitefeather.apus.operator.TenantUiConfig;
+import net.onelitefeather.apus.operator.api.Tenant;
+
+/**
+ * Turns a {@link Tenant} into the Kubernetes objects that serve it its own instance of the tenant
+ * application at {@code https:///t//}: a {@link Deployment}, a {@link Service} and
+ * an {@link Ingress}.
+ *
+ * Pure function -- no Kubernetes client, no side effects -- following the same shape as {@code
+ * net.onelitefeather.apus.operator.hosting.HostingResourceBuilder}. Labels and the owner reference
+ * are passed in rather than rebuilt here, so what lands on these three objects cannot drift from
+ * what {@link TenantReconciler} stamps on the namespace, the quota and the limit range.
+ *
+ *
One image serves every tenant. {@code NUXT_APP_BASE_URL} moves the served prefix at
+ * runtime (verified against the built image: started with {@code /t/acme/} the Nitro server serves
+ * its shell and deep links under that prefix, emits assets at {@code /t/acme/_nuxt/…}, and 404s on
+ * the bare root). A tenant instance therefore differs from the platform's own {@code ui}
+ * Deployment in exactly one environment variable, not in a per-tenant build.
+ */
+public final class TenantUiResourceBuilder {
+
+ /** Name shared by the Deployment, the Service and the Ingress in a tenant's own namespace. */
+ public static final String RESOURCE_NAME = "apus-tenant-ui";
+
+ /**
+ * The port the image listens on, pinned by {@code ui/Dockerfile} ({@code PORT=8080}). Nitro's
+ * own default is 3000 and is not what this image uses.
+ */
+ public static final int CONTAINER_PORT = 8080;
+
+ private static final String CONTAINER_NAME = "ui";
+
+ private static final String PORT_NAME = "http";
+
+ /**
+ * Requests are not optional here, and this is the one mistake in this class that would not
+ * show up in any test against a mock API server. A tenant namespace carries a {@code
+ * ResourceQuota} on {@code requests.cpu}/{@code requests.memory} and a {@code LimitRange} with
+ * no spec at all, so the quota makes both requests mandatory and nothing supplies a default.
+ * A Deployment without them is accepted and then never produces a pod.
+ *
+ *
The values match {@code ui.resources} in the platform chart, where they were measured
+ * against Nitro's actual shell-per-request profile rather than guessed.
+ */
+ private static final String CPU_REQUEST = "50m";
+
+ private static final String MEMORY_REQUEST = "128Mi";
+
+ private static final String MEMORY_LIMIT = "256Mi";
+
+ private TenantUiResourceBuilder() {}
+
+ /** The prefix this tenant's instance is served under, with the trailing slash Nuxt expects. */
+ public static String basePath(Tenant tenant) {
+ return "/t/" + tenant.getMetadata().getName() + "/";
+ }
+
+ /** The same prefix as an ingress path, which carries no trailing slash. */
+ public static String ingressPath(Tenant tenant) {
+ return "/t/" + tenant.getMetadata().getName();
+ }
+
+ /**
+ * The two redirect URIs the identity provider must have registered before anyone can sign in
+ * to this tenant's instance.
+ *
+ *
A wildcard is not an option even where the provider allows one: Entra strips the query
+ * string when a wildcard URI matches, and the authorization code lives in that query string.
+ * Nor can the operator register these itself -- that needs Microsoft Graph application
+ * permissions on the app registration, a grant that belongs to a different subsystem. So they
+ * are reported instead, on {@code Tenant.status.redirectUris}, because a missing registration
+ * fails at sign-in with {@code AADSTS50011} from the broker and leaves nothing at all in this
+ * cluster's logs to find.
+ */
+ public static List redirectUris(Tenant tenant, TenantUiConfig config) {
+ String prefix = "https://" + config.host() + basePath(tenant);
+ return List.of(prefix + "auth/callback", prefix + "auth/silent-renew");
+ }
+
+ /**
+ * Builds the {@link Deployment} running this tenant's instance.
+ *
+ * @param tenant the tenant whose instance this is; supplies the namespace and the base path
+ * @param config the image and the public runtime configuration every instance is handed
+ * @param labels the tenant-ownership labels, also used as the pod selector -- passed in so
+ * they cannot drift from what {@link TenantReconciler} stamps elsewhere
+ * @param owner the owning {@link Tenant}, so deleting it takes this with it
+ * @return the manifest, not yet submitted to the API server
+ */
+ public static Deployment deployment(
+ Tenant tenant, TenantUiConfig config, Map labels, OwnerReference owner) {
+ Container container = new ContainerBuilder()
+ .withName(CONTAINER_NAME)
+ .withImage(config.image())
+ .withPorts(containerPort())
+ .withEnv(env(tenant, config))
+ .withResources(resources())
+ .withSecurityContext(containerSecurityContext())
+ .withReadinessProbe(probe(tenant))
+ .withLivenessProbe(probe(tenant))
+ .build();
+
+ return new DeploymentBuilder()
+ .withNewMetadata()
+ .withName(RESOURCE_NAME)
+ .withNamespace(TenantReconciler.namespaceFor(tenant))
+ .withLabels(labels)
+ .withOwnerReferences(owner)
+ .endMetadata()
+ .withNewSpec()
+ .withReplicas(1)
+ .withNewSelector()
+ .withMatchLabels(labels)
+ .endSelector()
+ .withNewTemplate()
+ .withNewMetadata()
+ .withLabels(labels)
+ .endMetadata()
+ .withNewSpec()
+ .withContainers(container)
+ .withSecurityContext(podSecurityContext())
+ .endSpec()
+ .endTemplate()
+ .endSpec()
+ .build();
+ }
+
+ /**
+ * Builds the {@link Service} fronting this tenant's instance.
+ *
+ * @param tenant the tenant whose instance this is; supplies the namespace
+ * @param labels the tenant-ownership labels, also used as the pod selector
+ * @param owner the owning {@link Tenant}
+ * @return the manifest, not yet submitted to the API server
+ */
+ public static Service service(Tenant tenant, Map labels, OwnerReference owner) {
+ ServicePort port = new ServicePortBuilder()
+ .withName(PORT_NAME)
+ .withPort(CONTAINER_PORT)
+ .withNewTargetPort(CONTAINER_PORT)
+ .build();
+
+ return new ServiceBuilder()
+ .withNewMetadata()
+ .withName(RESOURCE_NAME)
+ .withNamespace(TenantReconciler.namespaceFor(tenant))
+ .withLabels(labels)
+ .withOwnerReferences(owner)
+ .endMetadata()
+ .withNewSpec()
+ .withSelector(labels)
+ .withPorts(port)
+ .endSpec()
+ .build();
+ }
+
+ /**
+ * Builds the {@link Ingress} exposing this tenant's instance at {@code /t/}.
+ *
+ * It has to be a per-tenant object, and it has to live in the tenant's namespace.
+ * An Ingress may only reference a Service in its own namespace, and each tenant's Service is
+ * in {@code bluemap-} -- so a single operator-owned Ingress listing every tenant's path
+ * is not available at any price. That it is also garbage-collected with the tenant, and keeps
+ * the platform chart's ingress a static file rather than something a controller writes to, is
+ * a bonus rather than the reason.
+ *
+ * Path ordering against the platform's own {@code /}, {@code /api} and {@code /console} is
+ * not this object's problem to solve: the cluster's tunnel controller flattens every Ingress
+ * into one rule list and sorts it by path length descending before appending its 404
+ * catch-all, so {@code /t/} lands ahead of {@code /} on its own.
+ *
+ * No annotations and no {@code tls} section: the tunnel controller already defaults
+ * {@code backend-protocol} to {@code http}, and TLS terminates at the edge, so a {@code tls}
+ * section here would ask for a certificate nobody issues.
+ *
+ * @param tenant the tenant whose instance this is; supplies the namespace and the path
+ * @param config the host and ingress class to expose it on
+ * @param labels the tenant-ownership labels
+ * @param owner the owning {@link Tenant}
+ * @return the manifest, not yet submitted to the API server
+ */
+ public static Ingress ingress(
+ Tenant tenant, TenantUiConfig config, Map labels, OwnerReference owner) {
+ var backend = new IngressBackendBuilder()
+ .withService(new IngressServiceBackendBuilder()
+ .withName(RESOURCE_NAME)
+ .withNewPort()
+ .withName(PORT_NAME)
+ .endPort()
+ .build())
+ .build();
+
+ var path = new HTTPIngressPathBuilder()
+ .withPath(ingressPath(tenant))
+ .withPathType("Prefix")
+ .withBackend(backend)
+ .build();
+
+ var rule = new IngressRuleBuilder()
+ .withHost(config.host())
+ .withNewHttp()
+ .withPaths(path)
+ .endHttp()
+ .build();
+
+ return new IngressBuilder()
+ .withNewMetadata()
+ .withName(RESOURCE_NAME)
+ .withNamespace(TenantReconciler.namespaceFor(tenant))
+ .withLabels(labels)
+ .withOwnerReferences(owner)
+ .endMetadata()
+ .withNewSpec()
+ .withIngressClassName(config.ingressClassName())
+ .withRules(rule)
+ .endSpec()
+ .build();
+ }
+
+ /**
+ * The instance's whole configuration. Every value is public by design -- this is a public OIDC
+ * client and all of it ends up in the served HTML -- so none of it comes from a Secret.
+ * {@code NUXT_APP_BASE_URL} is the only one that differs between tenants.
+ */
+ private static List env(Tenant tenant, TenantUiConfig config) {
+ return List.of(
+ literal("NUXT_APP_BASE_URL", basePath(tenant)),
+ literal("NUXT_PUBLIC_API_BASE_URL", config.apiBaseUrl()),
+ literal("NUXT_PUBLIC_OIDC_ISSUER", config.oidcIssuer()),
+ literal("NUXT_PUBLIC_OIDC_CLIENT_ID", config.oidcClientId()),
+ literal("NUXT_PUBLIC_OIDC_SCOPE", config.oidcScope()));
+ }
+
+ private static EnvVar literal(String name, String value) {
+ return new EnvVarBuilder().withName(name).withValue(value).build();
+ }
+
+ private static ContainerPort containerPort() {
+ return new ContainerPortBuilder()
+ .withName(PORT_NAME)
+ .withContainerPort(CONTAINER_PORT)
+ .build();
+ }
+
+ /**
+ * Both probes hit the tenant's own base path rather than {@code /}: with {@code
+ * NUXT_APP_BASE_URL} set, the bare root 404s, so a probe there would restart a perfectly
+ * healthy pod forever -- and would only start doing it once the feature was switched on in a
+ * real cluster.
+ */
+ private static Probe probe(Tenant tenant) {
+ return new ProbeBuilder()
+ .withNewHttpGet()
+ .withPath(basePath(tenant))
+ .withNewPort(CONTAINER_PORT)
+ .endHttpGet()
+ .withInitialDelaySeconds(5)
+ .withPeriodSeconds(10)
+ .build();
+ }
+
+ /**
+ * The same hardening the platform chart applies to its own {@code ui} Deployment, which runs
+ * this identical image. Not optional and not configurable: an instance created by a
+ * controller must not end up less restricted than the same software installed by Helm, and
+ * these four settings are exactly what {@code PodSecurity "restricted"} asks for -- a warning
+ * on every tenant pod today, and a rejection the moment a platform sets tenant namespaces to
+ * enforce it.
+ */
+ private static PodSecurityContext podSecurityContext() {
+ return new PodSecurityContextBuilder()
+ .withRunAsNonRoot(true)
+ // The distroless :nonroot base runs as 65532, unlike the Java images' 10001.
+ .withRunAsUser(65532L)
+ .withNewSeccompProfile()
+ .withType("RuntimeDefault")
+ .endSeccompProfile()
+ .build();
+ }
+
+ /** See {@link #podSecurityContext()}. The Nitro server only ever reads from the image. */
+ private static SecurityContext containerSecurityContext() {
+ return new SecurityContextBuilder()
+ .withAllowPrivilegeEscalation(false)
+ .withReadOnlyRootFilesystem(true)
+ .withNewCapabilities()
+ .withDrop("ALL")
+ .endCapabilities()
+ .build();
+ }
+
+ /** See {@link #CPU_REQUEST}: without these the namespace's quota rejects every pod. */
+ private static ResourceRequirements resources() {
+ return new ResourceRequirementsBuilder()
+ .withRequests(Map.of("cpu", new Quantity(CPU_REQUEST), "memory", new Quantity(MEMORY_REQUEST)))
+ .withLimits(Map.of("memory", new Quantity(MEMORY_LIMIT)))
+ .build();
+ }
+}
diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/OperatorConfigTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/OperatorConfigTest.java
index 44c64d7..619a9f1 100644
--- a/operator/src/test/java/net/onelitefeather/apus/operator/OperatorConfigTest.java
+++ b/operator/src/test/java/net/onelitefeather/apus/operator/OperatorConfigTest.java
@@ -18,6 +18,8 @@
package net.onelitefeather.apus.operator;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Map;
import org.junit.jupiter.api.Test;
@@ -80,4 +82,25 @@ void fromEnvironmentReadsAllVariables() {
assertEquals("eu-central-1", config.bundleS3Region());
assertEquals("bundle-creds-de", config.bundleCredentialsSecretName());
}
+
+ /**
+ * The per-tenant application instance is off unless a host is configured, and the defaults
+ * must leave it that way -- an operator that started provisioning a Deployment per tenant on
+ * a plain upgrade would be a surprise nobody asked for.
+ */
+ @Test
+ void theTenantApplicationInstanceIsOffByDefault() {
+ assertFalse(OperatorConfig.defaults().tenantUi().enabled());
+ assertFalse(OperatorConfig.fromEnvironment(name -> null).tenantUi().enabled());
+ }
+
+ @Test
+ void fromEnvironmentCarriesTheTenantUiSettings() {
+ Map env = Map.of("APUS_TENANT_UI_HOST", "apus.example.dev");
+
+ OperatorConfig config = OperatorConfig.fromEnvironment(env::get);
+
+ assertTrue(config.tenantUi().enabled());
+ assertEquals("apus.example.dev", config.tenantUi().host());
+ }
}
diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/OperatorIntegrationTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/OperatorIntegrationTest.java
index 38e353e..43b9c6b 100644
--- a/operator/src/test/java/net/onelitefeather/apus/operator/OperatorIntegrationTest.java
+++ b/operator/src/test/java/net/onelitefeather/apus/operator/OperatorIntegrationTest.java
@@ -32,11 +32,14 @@
import io.fabric8.kubernetes.client.KubernetesClientBuilder;
import io.javaoperatorsdk.operator.api.reconciler.UpdateControl;
import java.time.Duration;
+import java.util.List;
import net.onelitefeather.apus.operator.api.BlueMapMap;
import net.onelitefeather.apus.operator.api.Conditions;
+import net.onelitefeather.apus.operator.api.Labels;
import net.onelitefeather.apus.operator.api.Tenant;
import net.onelitefeather.apus.operator.map.BlueMapMapReconciler;
import net.onelitefeather.apus.operator.tenant.TenantReconciler;
+import net.onelitefeather.apus.operator.tenant.TenantUiResourceBuilder;
import net.onelitefeather.apus.operator.testsupport.K3sCrdSupport;
import org.junit.jupiter.api.Test;
import org.testcontainers.k3s.K3sContainer;
@@ -71,6 +74,35 @@ class OperatorIntegrationTest {
private static final Duration CRD_REGISTRATION_TIMEOUT = Duration.ofMinutes(2);
+ /**
+ * How long to wait for the ReplicaSet controller to create a pod for the tenant's application
+ * instance. Generous, because this waits on a real controller-manager inside a container; a
+ * quota rejection would be immediate, so a timeout here really does mean "never admitted".
+ */
+ private static final Duration POD_ADMISSION_TIMEOUT = Duration.ofMinutes(2);
+
+ /**
+ * Polls until a pod carrying the tenant application instance's labels exists, or the timeout
+ * passes. Same shape as {@code K3sCrdSupport#awaitCrdRegistration}, rather than pulling in an
+ * Awaitility dependency for a single call site.
+ */
+ private static boolean awaitTenantUiPod(KubernetesClient client, Duration timeout) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + timeout.toMillis();
+ while (System.currentTimeMillis() < deadline) {
+ boolean admitted = !client.pods()
+ .inNamespace("bluemap-uitest")
+ .withLabel(Labels.NAME, "tenant-ui")
+ .list()
+ .getItems()
+ .isEmpty();
+ if (admitted) {
+ return true;
+ }
+ Thread.sleep(2000);
+ }
+ return false;
+ }
+
@Test
void appliesGeneratedCrdsAndReconcilesATenant() throws Exception {
try (K3sContainer k3s = new K3sContainer(DockerImageName.parse("rancher/k3s:v1.31.2-k3s1"))) {
@@ -189,4 +221,103 @@ void reportsRookUnavailableForABlueMapMapWithoutThrowing() throws Exception {
}
}
}
+
+ /**
+ * The per-tenant application instance, against a real API server -- and specifically, against
+ * a real admission path.
+ *
+ * This is here because of one failure the mock server cannot reproduce at all. A tenant
+ * namespace carries a {@code ResourceQuota} on {@code requests.cpu}/{@code requests.memory}
+ * and a {@code LimitRange} with no spec, so the quota makes both requests mandatory for every
+ * pod and nothing supplies a default. A Deployment whose container omits them is accepted
+ * happily by the API server and then never produces a pod: the ReplicaSet controller's pod
+ * creations are rejected by quota admission, and what an operator sees is a healthy-looking
+ * Deployment stuck at zero replicas.
+ *
+ *
So this asserts a Pod exists, not a Deployment. k3s runs the real
+ * kube-controller-manager, so the ReplicaSet controller and the quota admission plugin both
+ * take part. The image itself never pulls here and the pod never becomes ready -- that is
+ * fine and beside the point. A Pod object existing at all is the proof that quota admission
+ * let it through.
+ */
+ @Test
+ void aTenantApplicationInstanceGetsPastTheNamespaceQuota() throws Exception {
+ try (K3sContainer k3s = new K3sContainer(DockerImageName.parse("rancher/k3s:v1.31.2-k3s1"))) {
+ k3s.start();
+
+ Config config = Config.fromKubeconfig(k3s.getKubeConfigYaml());
+ try (KubernetesClient client =
+ new KubernetesClientBuilder().withConfig(config).build()) {
+
+ K3sCrdSupport.applyGeneratedCrds(client);
+ K3sCrdSupport.awaitCrdRegistration(
+ client, "tenants.bluemap.onelitefeather.net", CRD_REGISTRATION_TIMEOUT);
+
+ Tenant tenant = new Tenant();
+ tenant.setMetadata(
+ new ObjectMetaBuilder().withName("uitest").build());
+ tenant.getSpec().setDisplayName("uitest");
+ Tenant created =
+ client.resources(Tenant.class).resource(tenant).create();
+
+ OperatorConfig defaults = OperatorConfig.defaults();
+ OperatorConfig withUi = new OperatorConfig(
+ defaults.rookNamespace(),
+ defaults.cephObjectStore(),
+ defaults.bucketStorageClass(),
+ defaults.runnerImage(),
+ defaults.ingestImage(),
+ defaults.hostingImage(),
+ defaults.bundleBucket(),
+ defaults.bundleS3Endpoint(),
+ defaults.bundleS3Region(),
+ defaults.bundleCredentialsSecretName(),
+ new TenantUiConfig(
+ "apus.example.dev",
+ "apus/ui:dev",
+ "traefik",
+ "https://apus.example.dev",
+ "https://issuer.example/v2.0",
+ "client-id",
+ "api://client-id/access_as_user openid"));
+
+ new TenantReconciler(client, withUi).reconcile(created, null);
+
+ assertNotNull(
+ client.apps()
+ .deployments()
+ .inNamespace("bluemap-uitest")
+ .withName(TenantUiResourceBuilder.RESOURCE_NAME)
+ .get(),
+ "reconciling a tenant with a host configured must create its application instance");
+
+ // The real point of this test. Only a cluster with quota admission enforces it.
+ // Polled the same way K3sCrdSupport polls for CRD registration, rather than
+ // adding an Awaitility dependency for one call site.
+ assertTrue(
+ awaitTenantUiPod(client, POD_ADMISSION_TIMEOUT),
+ "no pod was admitted -- the namespace's ResourceQuota rejected it, which is exactly what"
+ + " happens when the container declares no resource requests");
+
+ // The Ingress has to survive the API server's own validation, which the mock
+ // server does not perform: a bad pathType or a missing backend port would only
+ // ever show up here.
+ assertNotNull(
+ client.network()
+ .v1()
+ .ingresses()
+ .inNamespace("bluemap-uitest")
+ .withName(TenantUiResourceBuilder.RESOURCE_NAME)
+ .get(),
+ "the per-tenant Ingress must be accepted by a real API server");
+
+ assertEquals(
+ List.of(
+ "https://apus.example.dev/t/uitest/auth/callback",
+ "https://apus.example.dev/t/uitest/auth/silent-renew"),
+ created.getStatus().getRedirectUris(),
+ "the URIs an administrator still has to register must be patched back onto the tenant");
+ }
+ }
+ }
}
diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/TenantUiConfigTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/TenantUiConfigTest.java
new file mode 100644
index 0000000..b4992c5
--- /dev/null
+++ b/operator/src/test/java/net/onelitefeather/apus/operator/TenantUiConfigTest.java
@@ -0,0 +1,80 @@
+/**
+ * Apus - render and host BlueMap maps on Kubernetes.
+ * Copyright (C) 2026 OneLiteFeather and contributors
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.apus.operator;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class TenantUiConfigTest {
+
+ @Test
+ void isDisabledWhenNoHostIsConfigured() {
+ assertFalse(TenantUiConfig.fromEnvironment(name -> null).enabled());
+ assertEquals(TenantUiConfig.disabled(), TenantUiConfig.fromEnvironment(name -> null));
+ }
+
+ @Test
+ void isDisabledWhenTheHostIsBlank() {
+ Map env = Map.of("APUS_TENANT_UI_HOST", " ");
+
+ assertFalse(TenantUiConfig.fromEnvironment(env::get).enabled());
+ }
+
+ @Test
+ void isEnabledOnceAHostIsSet() {
+ Map env = Map.of("APUS_TENANT_UI_HOST", "apus.example.dev");
+
+ assertTrue(TenantUiConfig.fromEnvironment(env::get).enabled());
+ }
+
+ @Test
+ void readsEveryVariable() {
+ Map env = Map.ofEntries(
+ Map.entry("APUS_TENANT_UI_HOST", "apus.example.dev"),
+ Map.entry("APUS_TENANT_UI_IMAGE", "apus/ui:1.2.3"),
+ Map.entry("APUS_TENANT_UI_INGRESS_CLASS", "cloudflare-tunnel"),
+ Map.entry("APUS_TENANT_UI_API_BASE_URL", "https://apus.example.dev"),
+ Map.entry("APUS_TENANT_UI_OIDC_ISSUER", "https://issuer.example/v2.0"),
+ Map.entry("APUS_TENANT_UI_OIDC_CLIENT_ID", "client-id"),
+ Map.entry("APUS_TENANT_UI_OIDC_SCOPE", "api://client-id/access_as_user openid"));
+
+ TenantUiConfig config = TenantUiConfig.fromEnvironment(env::get);
+
+ assertEquals("apus.example.dev", config.host());
+ assertEquals("apus/ui:1.2.3", config.image());
+ assertEquals("cloudflare-tunnel", config.ingressClassName());
+ assertEquals("https://apus.example.dev", config.apiBaseUrl());
+ assertEquals("https://issuer.example/v2.0", config.oidcIssuer());
+ assertEquals("client-id", config.oidcClientId());
+ assertEquals("api://client-id/access_as_user openid", config.oidcScope());
+ }
+
+ @Test
+ void fallsBackToTheDefaultImageAndIngressClass() {
+ Map env = Map.of("APUS_TENANT_UI_HOST", "apus.example.dev");
+
+ TenantUiConfig config = TenantUiConfig.fromEnvironment(env::get);
+
+ assertEquals("apus/ui:dev", config.image());
+ assertEquals("nginx", config.ingressClassName());
+ }
+}
diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/api/ApusResourceTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/api/ApusResourceTest.java
index e1b445a..9d15ff7 100644
--- a/operator/src/test/java/net/onelitefeather/apus/operator/api/ApusResourceTest.java
+++ b/operator/src/test/java/net/onelitefeather/apus/operator/api/ApusResourceTest.java
@@ -134,4 +134,22 @@ void everyResourceHasANonNullSpecAndStatusRightAfterConstruction() {
new BlueMapRender().getStatus(),
"BlueMapRender.getStatus() must not be null right after construction");
}
+
+ @Test
+ void aTenantStartsWithNoRedirectUris() {
+ // A tenant with no application instance must report an empty list, never null: every
+ // reader of this field would otherwise have to guard, and the console renders nothing
+ // at all rather than an empty "redirect URIs" box.
+ assertTrue(new TenantStatus().getRedirectUris().isEmpty());
+ }
+
+ @Test
+ void aTenantStatusAbsorbsNullRedirectUris() {
+ // `kubectl edit` and a round-trip through Jackson can both put null here.
+ TenantStatus status = new TenantStatus();
+
+ status.setRedirectUris(null);
+
+ assertTrue(status.getRedirectUris().isEmpty());
+ }
}
diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/hosting/BlueMapHostingReconcilerTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/hosting/BlueMapHostingReconcilerTest.java
index f9a357f..66041b1 100644
--- a/operator/src/test/java/net/onelitefeather/apus/operator/hosting/BlueMapHostingReconcilerTest.java
+++ b/operator/src/test/java/net/onelitefeather/apus/operator/hosting/BlueMapHostingReconcilerTest.java
@@ -37,6 +37,7 @@
import java.util.List;
import java.util.UUID;
import net.onelitefeather.apus.operator.OperatorConfig;
+import net.onelitefeather.apus.operator.TenantUiConfig;
import net.onelitefeather.apus.operator.api.BlueMapHosting;
import net.onelitefeather.apus.operator.api.BlueMapMap;
import net.onelitefeather.apus.operator.api.Conditions;
@@ -293,7 +294,8 @@ void deploymentUsesTheHostingImageFromOperatorConfig() {
"apus-bundles",
"http://rgw.example.svc:80",
"us-east-1",
- "apus-bundle-credentials");
+ "apus-bundle-credentials",
+ TenantUiConfig.disabled());
BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, config);
BlueMapHosting hosting = hosting("friends-maps", "map.friends.example.net", "survival-overworld");
diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/ingest/IngestJobBuilderTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/ingest/IngestJobBuilderTest.java
index 6ee4f70..9739090 100644
--- a/operator/src/test/java/net/onelitefeather/apus/operator/ingest/IngestJobBuilderTest.java
+++ b/operator/src/test/java/net/onelitefeather/apus/operator/ingest/IngestJobBuilderTest.java
@@ -37,6 +37,7 @@
import java.util.function.Function;
import java.util.stream.Collectors;
import net.onelitefeather.apus.operator.OperatorConfig;
+import net.onelitefeather.apus.operator.TenantUiConfig;
import net.onelitefeather.apus.operator.api.Labels;
import net.onelitefeather.apus.operator.api.WorldIngest;
import net.onelitefeather.apus.operator.api.WorldSource;
@@ -227,7 +228,8 @@ void placesTheContainerImageFromTheOperatorConfig() {
"apus-bundles",
"http://rgw.example.svc:80",
"us-east-1",
- "apus-bundle-credentials");
+ "apus-bundle-credentials",
+ TenantUiConfig.disabled());
Job job = IngestJobBuilder.build(ingest("i1", "v1"), s3Source("survival-source"), config);
diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/render/RenderJobBuilderTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/render/RenderJobBuilderTest.java
index 3d093b6..d806e68 100644
--- a/operator/src/test/java/net/onelitefeather/apus/operator/render/RenderJobBuilderTest.java
+++ b/operator/src/test/java/net/onelitefeather/apus/operator/render/RenderJobBuilderTest.java
@@ -32,6 +32,7 @@
import java.util.function.Function;
import java.util.stream.Collectors;
import net.onelitefeather.apus.operator.OperatorConfig;
+import net.onelitefeather.apus.operator.TenantUiConfig;
import net.onelitefeather.apus.operator.api.BlueMapMap;
import net.onelitefeather.apus.operator.api.BlueMapRender;
import net.onelitefeather.apus.operator.api.Labels;
@@ -159,7 +160,8 @@ void placesTheContainerImageFromTheOperatorConfig() {
"apus-bundles",
"http://rgw.rook-ceph-fr01.svc:80",
"us-east-1",
- "apus-bundle-credentials");
+ "apus-bundle-credentials",
+ TenantUiConfig.disabled());
Job job = RenderJobBuilder.build(render(), map(), "bucket-secret", config);
diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/tenant/TenantReconcilerTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/tenant/TenantReconcilerTest.java
index c8730cb..283a071 100644
--- a/operator/src/test/java/net/onelitefeather/apus/operator/tenant/TenantReconcilerTest.java
+++ b/operator/src/test/java/net/onelitefeather/apus/operator/tenant/TenantReconcilerTest.java
@@ -19,6 +19,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.fabric8.kubernetes.api.model.Namespace;
@@ -27,13 +28,16 @@
import io.fabric8.kubernetes.api.model.ResourceQuota;
import io.fabric8.kubernetes.api.model.Secret;
import io.fabric8.kubernetes.api.model.SecretBuilder;
+import io.fabric8.kubernetes.api.model.apps.Deployment;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient;
import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer;
import io.javaoperatorsdk.operator.api.reconciler.UpdateControl;
+import java.util.List;
import java.util.Map;
import java.util.UUID;
import net.onelitefeather.apus.operator.OperatorConfig;
+import net.onelitefeather.apus.operator.TenantUiConfig;
import net.onelitefeather.apus.operator.api.Conditions;
import net.onelitefeather.apus.operator.api.Labels;
import net.onelitefeather.apus.operator.api.Tenant;
@@ -428,4 +432,147 @@ void refusesToAdoptAPushTokenSecretOwnedByAnotherTenant() {
assertTrue(control.isPatchStatus());
assertEquals(TenantReconciler.RESOURCE_CONFLICT_REASON, readyReason(tenant));
}
+
+ /**
+ * The defaults with the per-tenant application instance switched on. Everything else is copied
+ * from {@link OperatorConfig#defaults()} so this helper cannot drift from it.
+ */
+ private static OperatorConfig configWithTenantUi() {
+ OperatorConfig defaults = OperatorConfig.defaults();
+ return new OperatorConfig(
+ defaults.rookNamespace(),
+ defaults.cephObjectStore(),
+ defaults.bucketStorageClass(),
+ defaults.runnerImage(),
+ defaults.ingestImage(),
+ defaults.hostingImage(),
+ defaults.bundleBucket(),
+ defaults.bundleS3Endpoint(),
+ defaults.bundleS3Region(),
+ defaults.bundleCredentialsSecretName(),
+ new TenantUiConfig(
+ "apus.example.dev",
+ "apus/ui:1.2.3",
+ "cloudflare-tunnel",
+ "https://apus.example.dev",
+ "https://issuer.example/v2.0",
+ "client-id",
+ "api://client-id/access_as_user openid"));
+ }
+
+ private Deployment tenantUiDeployment(String namespace) {
+ return client.apps()
+ .deployments()
+ .inNamespace(namespace)
+ .withName(TenantUiResourceBuilder.RESOURCE_NAME)
+ .get();
+ }
+
+ /**
+ * The default, and the reason it is asserted first: a platform that has not opted in must get
+ * no per-tenant instance at all. An operator that started provisioning a Deployment per tenant
+ * on a plain upgrade would be a surprise nobody asked for, and one that costs a pod per
+ * tenant.
+ */
+ @Test
+ void provisionsNoApplicationInstanceWhenNoHostIsConfigured() {
+ TenantReconciler reconciler = new TenantReconciler(client, OperatorConfig.defaults());
+ Tenant tenant = tenant("friends", "500Gi");
+
+ reconciler.reconcile(tenant, null);
+
+ assertNull(tenantUiDeployment("bluemap-friends"));
+ assertNull(client.services()
+ .inNamespace("bluemap-friends")
+ .withName(TenantUiResourceBuilder.RESOURCE_NAME)
+ .get());
+ assertNull(client.network()
+ .v1()
+ .ingresses()
+ .inNamespace("bluemap-friends")
+ .withName(TenantUiResourceBuilder.RESOURCE_NAME)
+ .get());
+ assertTrue(tenant.getStatus().getRedirectUris().isEmpty());
+ }
+
+ @Test
+ void provisionsTheApplicationInstanceOnceAHostIsConfigured() {
+ TenantReconciler reconciler = new TenantReconciler(client, configWithTenantUi());
+ Tenant tenant = tenant("friends", "500Gi");
+
+ reconciler.reconcile(tenant, null);
+
+ Deployment deployment = tenantUiDeployment("bluemap-friends");
+ assertNotNull(deployment);
+ assertEquals(
+ "apus/ui:1.2.3",
+ deployment
+ .getSpec()
+ .getTemplate()
+ .getSpec()
+ .getContainers()
+ .get(0)
+ .getImage());
+ assertNotNull(client.services()
+ .inNamespace("bluemap-friends")
+ .withName(TenantUiResourceBuilder.RESOURCE_NAME)
+ .get());
+ assertNotNull(client.network()
+ .v1()
+ .ingresses()
+ .inNamespace("bluemap-friends")
+ .withName(TenantUiResourceBuilder.RESOURCE_NAME)
+ .get());
+ }
+
+ @Test
+ void reportsTheRedirectUrisThatStillHaveToBeRegisteredByHand() {
+ TenantReconciler reconciler = new TenantReconciler(client, configWithTenantUi());
+ Tenant tenant = tenant("friends", "500Gi");
+
+ reconciler.reconcile(tenant, null);
+
+ assertEquals(
+ List.of(
+ "https://apus.example.dev/t/friends/auth/callback",
+ "https://apus.example.dev/t/friends/auth/silent-renew"),
+ tenant.getStatus().getRedirectUris());
+ }
+
+ @Test
+ void reconcilingTwiceLeavesOneApplicationInstance() {
+ TenantReconciler reconciler = new TenantReconciler(client, configWithTenantUi());
+ Tenant tenant = tenant("friends", "500Gi");
+
+ reconciler.reconcile(tenant, null);
+ reconciler.reconcile(tenant, null);
+
+ assertEquals(
+ 1,
+ client.apps()
+ .deployments()
+ .inNamespace("bluemap-friends")
+ .list()
+ .getItems()
+ .size());
+ }
+
+ /**
+ * The instance's labels double as the Deployment's pod selector, so they must not be the
+ * plain tenant label set every other resource here carries -- two workloads in one namespace
+ * sharing a selector would each take the other's pods.
+ */
+ @Test
+ void theApplicationInstanceSelectorIsNotTheTenantsGenericLabelSet() {
+ TenantReconciler reconciler = new TenantReconciler(client, configWithTenantUi());
+ Tenant tenant = tenant("friends", "500Gi");
+
+ reconciler.reconcile(tenant, null);
+
+ Map selector =
+ tenantUiDeployment("bluemap-friends").getSpec().getSelector().getMatchLabels();
+ assertEquals("tenant-ui", selector.get(Labels.NAME));
+ assertEquals("friends", selector.get(Labels.TENANT));
+ assertEquals(tenant.getMetadata().getUid(), selector.get(Labels.TENANT_UID));
+ }
}
diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilderTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilderTest.java
new file mode 100644
index 0000000..4c24070
--- /dev/null
+++ b/operator/src/test/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilderTest.java
@@ -0,0 +1,220 @@
+/**
+ * Apus - render and host BlueMap maps on Kubernetes.
+ * Copyright (C) 2026 OneLiteFeather and contributors
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.apus.operator.tenant;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.fabric8.kubernetes.api.model.Container;
+import io.fabric8.kubernetes.api.model.EnvVar;
+import io.fabric8.kubernetes.api.model.ObjectMeta;
+import io.fabric8.kubernetes.api.model.OwnerReference;
+import io.fabric8.kubernetes.api.model.OwnerReferenceBuilder;
+import io.fabric8.kubernetes.api.model.apps.Deployment;
+import io.fabric8.kubernetes.api.model.networking.v1.Ingress;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import net.onelitefeather.apus.operator.TenantUiConfig;
+import net.onelitefeather.apus.operator.api.Labels;
+import net.onelitefeather.apus.operator.api.Tenant;
+import org.junit.jupiter.api.Test;
+
+class TenantUiResourceBuilderTest {
+
+ private static final OwnerReference OWNER = new OwnerReferenceBuilder()
+ .withApiVersion("bluemap.onelitefeather.net/v1alpha1")
+ .withKind("Tenant")
+ .withName("acme")
+ .withUid("uid-1")
+ .withController(true)
+ .build();
+
+ private static final Map LABELS = Map.of(
+ Labels.MANAGED_BY, Labels.MANAGED_BY_VALUE,
+ Labels.TENANT, "acme",
+ Labels.TENANT_UID, "uid-1");
+
+ private static Tenant tenant() {
+ Tenant tenant = new Tenant();
+ tenant.getMetadata().setName("acme");
+ tenant.getMetadata().setUid("uid-1");
+ return tenant;
+ }
+
+ private static TenantUiConfig config() {
+ return new TenantUiConfig(
+ "apus.example.dev",
+ "apus/ui:1.2.3",
+ "cloudflare-tunnel",
+ "https://apus.example.dev",
+ "https://issuer.example/v2.0",
+ "client-id",
+ "api://client-id/access_as_user openid");
+ }
+
+ private static Container containerOf(Deployment deployment) {
+ return deployment.getSpec().getTemplate().getSpec().getContainers().get(0);
+ }
+
+ private static Map envOf(Deployment deployment) {
+ return containerOf(deployment).getEnv().stream().collect(Collectors.toMap(EnvVar::getName, EnvVar::getValue));
+ }
+
+ @Test
+ void theBasePathHasATrailingSlashAndTheIngressPathDoesNot() {
+ assertEquals("/t/acme/", TenantUiResourceBuilder.basePath(tenant()));
+ assertEquals("/t/acme", TenantUiResourceBuilder.ingressPath(tenant()));
+ }
+
+ @Test
+ void theDeploymentServesTheTenantsOwnBasePath() {
+ Deployment deployment = TenantUiResourceBuilder.deployment(tenant(), config(), LABELS, OWNER);
+
+ assertEquals("/t/acme/", envOf(deployment).get("NUXT_APP_BASE_URL"));
+ }
+
+ @Test
+ void theDeploymentCarriesThePublicRuntimeConfiguration() {
+ Map env = envOf(TenantUiResourceBuilder.deployment(tenant(), config(), LABELS, OWNER));
+
+ assertEquals("https://apus.example.dev", env.get("NUXT_PUBLIC_API_BASE_URL"));
+ assertEquals("https://issuer.example/v2.0", env.get("NUXT_PUBLIC_OIDC_ISSUER"));
+ assertEquals("client-id", env.get("NUXT_PUBLIC_OIDC_CLIENT_ID"));
+ assertEquals("api://client-id/access_as_user openid", env.get("NUXT_PUBLIC_OIDC_SCOPE"));
+ }
+
+ @Test
+ void theDeploymentRunsTheConfiguredImageInTheTenantNamespace() {
+ Deployment deployment = TenantUiResourceBuilder.deployment(tenant(), config(), LABELS, OWNER);
+
+ assertEquals("bluemap-acme", deployment.getMetadata().getNamespace());
+ assertEquals("apus/ui:1.2.3", containerOf(deployment).getImage());
+ }
+
+ /**
+ * A tenant namespace carries a {@code ResourceQuota} on {@code requests.cpu}/{@code
+ * requests.memory}, and the {@code LimitRange} beside it has no spec at all ({@code
+ * spec.limits: null} on the live cluster). A quota on a compute resource makes that request
+ * mandatory for every pod, and an empty limit range supplies no default to fall back on -- so
+ * a Deployment without requests is accepted by the API server and then never produces a pod.
+ * That failure looks like a healthy Deployment with zero replicas and no event worth reading.
+ */
+ @Test
+ void theDeploymentDeclaresResourceRequestsOrTheQuotaWouldRejectEveryPod() {
+ Container container = containerOf(TenantUiResourceBuilder.deployment(tenant(), config(), LABELS, OWNER));
+
+ assertNotNull(container.getResources());
+ assertEquals("50m", container.getResources().getRequests().get("cpu").toString());
+ assertEquals("128Mi", container.getResources().getRequests().get("memory").toString());
+ }
+
+ /**
+ * The platform chart hardens its own {@code ui} pod, and this runs the identical image. An
+ * instance that skipped it would be the same software running with fewer restrictions purely
+ * because a controller created it rather than Helm -- and it trips a {@code
+ * PodSecurity "restricted"} warning on every tenant pod today, which becomes a rejection the
+ * moment anyone sets that namespace to enforce.
+ */
+ @Test
+ void theInstanceIsHardenedTheSameWayThePlatformHardensTheSameImage() {
+ Deployment deployment = TenantUiResourceBuilder.deployment(tenant(), config(), LABELS, OWNER);
+ var pod = deployment.getSpec().getTemplate().getSpec();
+ var security = containerOf(deployment).getSecurityContext();
+
+ assertEquals(Boolean.TRUE, pod.getSecurityContext().getRunAsNonRoot());
+ // The distroless :nonroot base runs as 65532, not the 10001 the Java images use.
+ assertEquals(65532L, pod.getSecurityContext().getRunAsUser());
+ assertEquals(
+ "RuntimeDefault", pod.getSecurityContext().getSeccompProfile().getType());
+ assertEquals(Boolean.FALSE, security.getAllowPrivilegeEscalation());
+ assertEquals(Boolean.TRUE, security.getReadOnlyRootFilesystem());
+ assertEquals(List.of("ALL"), security.getCapabilities().getDrop());
+ }
+
+ /**
+ * With {@code NUXT_APP_BASE_URL} set, the bare root 404s -- a probe there would restart a
+ * perfectly healthy pod forever, and it would do so only once the feature was actually
+ * switched on in a cluster.
+ */
+ @Test
+ void theProbesHitTheTenantsBasePathRatherThanTheRoot() {
+ Container container = containerOf(TenantUiResourceBuilder.deployment(tenant(), config(), LABELS, OWNER));
+
+ assertEquals("/t/acme/", container.getReadinessProbe().getHttpGet().getPath());
+ assertEquals("/t/acme/", container.getLivenessProbe().getHttpGet().getPath());
+ }
+
+ @Test
+ void everyResourceIsOwnedByTheTenantAndLabelledLikeTheRest() {
+ var deployment = TenantUiResourceBuilder.deployment(tenant(), config(), LABELS, OWNER);
+ var service = TenantUiResourceBuilder.service(tenant(), LABELS, OWNER);
+ var ingress = TenantUiResourceBuilder.ingress(tenant(), config(), LABELS, OWNER);
+
+ for (ObjectMeta meta : List.of(deployment.getMetadata(), service.getMetadata(), ingress.getMetadata())) {
+ assertEquals("apus-tenant-ui", meta.getName());
+ assertEquals("bluemap-acme", meta.getNamespace());
+ assertEquals(LABELS, meta.getLabels());
+ assertEquals(List.of(OWNER), meta.getOwnerReferences());
+ }
+ }
+
+ @Test
+ void theServiceTargetsTheContainerPortTheImageActuallyListensOn() {
+ var port = TenantUiResourceBuilder.service(tenant(), LABELS, OWNER)
+ .getSpec()
+ .getPorts()
+ .get(0);
+
+ assertEquals("http", port.getName());
+ assertEquals(8080, port.getPort());
+ assertEquals(8080, port.getTargetPort().getIntVal());
+ }
+
+ @Test
+ void theIngressRoutesTheTenantPathOnTheConfiguredHost() {
+ Ingress ingress = TenantUiResourceBuilder.ingress(tenant(), config(), LABELS, OWNER);
+ var rule = ingress.getSpec().getRules().get(0);
+ var path = rule.getHttp().getPaths().get(0);
+
+ assertEquals("cloudflare-tunnel", ingress.getSpec().getIngressClassName());
+ assertEquals("apus.example.dev", rule.getHost());
+ assertEquals("/t/acme", path.getPath());
+ // The tunnel controller rejects any pathType but Prefix or ImplementationSpecific.
+ assertEquals("Prefix", path.getPathType());
+ assertEquals("apus-tenant-ui", path.getBackend().getService().getName());
+ }
+
+ /** TLS terminates at the edge; a tls section here would ask for a certificate nobody issues. */
+ @Test
+ void theIngressAsksForNoTls() {
+ Ingress ingress = TenantUiResourceBuilder.ingress(tenant(), config(), LABELS, OWNER);
+
+ assertTrue(ingress.getSpec().getTls() == null || ingress.getSpec().getTls().isEmpty());
+ }
+
+ @Test
+ void theRedirectUrisAreTheTwoEntraMustHaveRegistered() {
+ assertEquals(
+ List.of(
+ "https://apus.example.dev/t/acme/auth/callback",
+ "https://apus.example.dev/t/acme/auth/silent-renew"),
+ TenantUiResourceBuilder.redirectUris(tenant(), config()));
+ }
+}
diff --git a/ui/apps/console/app/components/platform/RedirectUris.vue b/ui/apps/console/app/components/platform/RedirectUris.vue
new file mode 100644
index 0000000..1c35076
--- /dev/null
+++ b/ui/apps/console/app/components/platform/RedirectUris.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+ Redirect URIs
+
+
+
+
+ This tenant has its own instance of the application. Before anyone can sign in to it, these
+ two URIs have to be registered with the identity provider — Apus cannot add them itself.
+
+
+
+
+
+
+ Until they are registered, signing in fails at the identity provider and nothing appears in
+ this cluster's logs.
+
+
+
+
+ {{ copied ? 'Copied' : 'Copy both' }}
+
+
+
+
+
diff --git a/ui/apps/console/app/pages/tenants/[name].vue b/ui/apps/console/app/pages/tenants/[name].vue
index 357cfde..a5b2005 100644
--- a/ui/apps/console/app/pages/tenants/[name].vue
+++ b/ui/apps/console/app/pages/tenants/[name].vue
@@ -195,6 +195,8 @@ const metadata = computed(() => {
+
+
Details
diff --git a/ui/apps/console/tests/nuxt/redirectUris.nuxt.spec.ts b/ui/apps/console/tests/nuxt/redirectUris.nuxt.spec.ts
new file mode 100644
index 0000000..7e69cbb
--- /dev/null
+++ b/ui/apps/console/tests/nuxt/redirectUris.nuxt.spec.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest'
+import { mountSuspended } from '@nuxt/test-utils/runtime'
+import RedirectUris from '~/components/platform/RedirectUris.vue'
+
+const URIS = [
+ 'https://apus.example.dev/t/acme/auth/callback',
+ 'https://apus.example.dev/t/acme/auth/silent-renew'
+]
+
+describe('RedirectUris', () => {
+ it('shows both URIs verbatim, because they are pasted somewhere else character for character', async () => {
+ const wrapper = await mountSuspended(RedirectUris, { props: { uris: URIS } })
+
+ expect(wrapper.text()).toContain('https://apus.example.dev/t/acme/auth/callback')
+ expect(wrapper.text()).toContain('https://apus.example.dev/t/acme/auth/silent-renew')
+ })
+
+ it('says this is a step someone still has to take, not a status report', async () => {
+ // The whole reason this exists: nothing in the cluster reports the failure, so the person
+ // who just created the tenant has to be told here or they walk away thinking it is done.
+ const wrapper = await mountSuspended(RedirectUris, { props: { uris: URIS } })
+
+ expect(wrapper.text()).toContain('identity provider')
+ })
+
+ it('renders nothing at all for a tenant with no instance', async () => {
+ // Not an empty box with a heading: a tenant without an application instance has no such
+ // step pending, and a "Redirect URIs" section with nothing under it reads like a bug.
+ const wrapper = await mountSuspended(RedirectUris, { props: { uris: [] } })
+
+ expect(wrapper.text().trim()).toBe('')
+ })
+})
diff --git a/ui/layers/core/app/utils/apiTypes.ts b/ui/layers/core/app/utils/apiTypes.ts
index 493620e..1f33498 100644
--- a/ui/layers/core/app/utils/apiTypes.ts
+++ b/ui/layers/core/app/utils/apiTypes.ts
@@ -66,6 +66,13 @@ export interface TenantResponse {
namespace: string
objectStoreUser: string
storageUsedBytes: number | null
+ /**
+ * The redirect URIs the identity provider must have registered before anyone can sign in to
+ * this tenant's own application instance. Empty when the tenant has no instance. The operator
+ * cannot register them itself, and a missing registration fails at the provider with nothing
+ * to see in the cluster -- which is why they are shown rather than filed in a runbook.
+ */
+ redirectUris: string[]
conditions: ConditionResponse[]
}