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 @@ + + + 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[] }