From f58f932695b817776224e5bae5ae31c1ce00d685 Mon Sep 17 00:00:00 2001 From: Phillipp Glanz Date: Sun, 16 Aug 2026 23:06:11 +0200 Subject: [PATCH 01/10] docs: design an app UI instance per tenant Every tenant gets its own deployed instance of the tenant application under /t// on the existing host, provisioned by TenantReconciler. Four findings settled the design, each read from the source or the cluster rather than assumed -- two of them contradicted the first draft: - one image serves any prefix via NUXT_APP_BASE_URL, verified against the built image, so this is one env var and not a per-tenant build - Entra permits wildcard redirect URIs here but strips the query string when one matches, which is where the auth code lives; two explicit URIs per tenant it is, ~128 tenants against the 256-URI limit - the ingress class is cloudflare-tunnel, not nginx: it flattens every Ingress in the cluster into one rule list sorted by path length descending, which is what actually makes a separate per-tenant Ingress work here - a tenant namespace's ResourceQuota makes resource requests mandatory and its LimitRange is empty (spec.limits: null on the live cluster), so a Deployment without requests would be created and never produce a pod Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019Bff5mpWkUnZA77jys8DiR --- ...26-08-16-per-tenant-app-instance-design.md | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-16-per-tenant-app-instance-design.md 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..bb31d77 --- /dev/null +++ b/docs/superpowers/specs/2026-08-16-per-tenant-app-instance-design.md @@ -0,0 +1,172 @@ +# 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. + +## 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 | +| 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. From 742b310d1fcc3ecc2983f8a4c6503366d7329f58 Mon Sep 17 00:00:00 2001 From: Phillipp Glanz Date: Sun, 16 Aug 2026 23:09:25 +0200 Subject: [PATCH 02/10] docs: plan the per-tenant app UI instance Seven tasks: TenantUiConfig, a pure TenantUiResourceBuilder, the redirect URIs on Tenant.status, the reconciler, the chart, the console display, and a verification pass that insists on a real API server for the one thing the mock cannot check -- that the namespace quota actually admits the pod. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019Bff5mpWkUnZA77jys8DiR --- .../2026-08-16-per-tenant-app-instance.md | 1118 +++++++++++++++++ 1 file changed, 1118 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-16-per-tenant-app-instance.md 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..003bdfb --- /dev/null +++ b/docs/superpowers/plans/2026-08-16-per-tenant-app-instance.md @@ -0,0 +1,1118 @@ +# 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. From e2734b175672e5dc8e3e51e062da0c88502be10e Mon Sep 17 00:00:00 2001 From: Phillipp Glanz Date: Sun, 16 Aug 2026 23:12:39 +0200 Subject: [PATCH 03/10] feat(operator): configure the per-tenant application instance TenantUiConfig carries the seven settings a per-tenant instance of the tenant application needs, read from APUS_TENANT_UI_* the same way the rest of OperatorConfig reads its environment. A blank host means the feature is off, and off is the default -- asserted, not assumed: an operator that started provisioning a Deployment per tenant on a plain upgrade would be a surprise nobody asked for. The four NUXT_PUBLIC_* values are modelled one by one rather than as a map. A map would have to be serialised through a single environment variable and would lose its schema, its documentation, and the ability to test each value. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019Bff5mpWkUnZA77jys8DiR --- .../apus/operator/OperatorConfig.java | 16 +++- .../apus/operator/TenantUiConfig.java | 96 +++++++++++++++++++ .../apus/operator/OperatorConfigTest.java | 23 +++++ .../apus/operator/TenantUiConfigTest.java | 80 ++++++++++++++++ .../hosting/BlueMapHostingReconcilerTest.java | 4 +- .../operator/ingest/IngestJobBuilderTest.java | 4 +- .../operator/render/RenderJobBuilderTest.java | 4 +- 7 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 operator/src/main/java/net/onelitefeather/apus/operator/TenantUiConfig.java create mode 100644 operator/src/test/java/net/onelitefeather/apus/operator/TenantUiConfigTest.java 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/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/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/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); From f5b1b4f29a1c092d986300ccbc973f19dd395a83 Mon Sep 17 00:00:00 2001 From: Phillipp Glanz Date: Sun, 16 Aug 2026 23:14:36 +0200 Subject: [PATCH 04/10] feat(operator): build the per-tenant application instance manifests TenantUiResourceBuilder turns a Tenant into the Deployment, Service and Ingress that serve it its own instance at https:///t//. Pure function, following HostingResourceBuilder; labels and the owner reference are passed in so they cannot drift from what TenantReconciler stamps on everything else. Three things here are load bearing and each has a test that says why: - resource requests, because the tenant namespace's quota makes them mandatory and its limit range supplies no default -- without them the Deployment is created and never produces a pod, which no mock API server would catch - probes on /t// rather than /, because the bare root 404s once NUXT_APP_BASE_URL is set and a root probe would restart a healthy pod forever - the Ingress being per-tenant and in the tenant's namespace, because an Ingress may only reference a Service in its own namespace Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019Bff5mpWkUnZA77jys8DiR --- .../tenant/TenantUiResourceBuilder.java | 312 ++++++++++++++++++ .../tenant/TenantUiResourceBuilderTest.java | 197 +++++++++++ 2 files changed, 509 insertions(+) create mode 100644 operator/src/main/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilder.java create mode 100644 operator/src/test/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilderTest.java 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..27b3cb5 --- /dev/null +++ b/operator/src/main/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilder.java @@ -0,0 +1,312 @@ +/** + * 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.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, 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()) + .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(); + } + + /** + * 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(); + } + + /** 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/tenant/TenantUiResourceBuilderTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilderTest.java new file mode 100644 index 0000000..00b9ed2 --- /dev/null +++ b/operator/src/test/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilderTest.java @@ -0,0 +1,197 @@ +/** + * 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()); + } + + /** + * 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())); + } +} From 52786dccef7c2cfcc40b413e035c5f8a6b4703a1 Mon Sep 17 00:00:00 2001 From: Phillipp Glanz Date: Sun, 16 Aug 2026 23:16:08 +0200 Subject: [PATCH 05/10] feat(operator): report the redirect URIs a tenant instance needs Tenant.status.redirectUris carries the two URIs the identity provider must have registered before anyone can sign in to a tenant's own instance. The operator cannot register them itself -- that needs Microsoft Graph application permissions on the app registration -- and a missing registration fails at sign-in with AADSTS50011 from the broker, leaving nothing in this cluster's logs. So kubectl answers the question instead. The list absorbs null and starts empty, both asserted: every reader would otherwise have to guard, and the console must render nothing at all rather than an empty box for a tenant that has no instance. The regenerated CRD adds four lines and nothing else. The generator does not emit the chart's hand-added helm.sh/resource-policy: keep annotation, so it is restored -- without it a helm uninstall would delete every Tenant in the cluster. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019Bff5mpWkUnZA77jys8DiR --- ...tenants.bluemap.onelitefeather.net-v1.yaml | 4 ++++ .../apus/operator/api/TenantStatus.java | 22 +++++++++++++++++++ .../apus/operator/api/ApusResourceTest.java | 18 +++++++++++++++ 3 files changed, 44 insertions(+) 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/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/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()); + } } From 62663007d04c5f998792ce88fc09ae0df59a2657 Mon Sep 17 00:00:00 2001 From: Phillipp Glanz Date: Sun, 16 Aug 2026 23:19:48 +0200 Subject: [PATCH 06/10] feat(operator): provision an application instance per tenant TenantReconciler now creates a Deployment, Service and Ingress in each tenant's namespace, serving that tenant its own instance of the tenant application at https:///t//, and reports the two redirect URIs that still have to be registered by hand. Off unless a host is configured, which is the default and the first thing the tests assert -- an operator that started provisioning a pod per tenant on a plain upgrade would be a surprise nobody asked for. Switching the feature back off clears status.redirectUris rather than leaving it advertising an instance that no longer exists. The instance's labels are deliberately not the tenant label set every other resource here carries: they double as the Deployment's pod selector, and two workloads in one namespace sharing a selector would each take the other's pods. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019Bff5mpWkUnZA77jys8DiR --- .../operator/tenant/TenantReconciler.java | 73 +++++++++ .../operator/tenant/TenantReconcilerTest.java | 147 ++++++++++++++++++ 2 files changed, 220 insertions(+) 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/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)); + } } From 97907fea58f39c5f7e3d11ba5640606945b8c403 Mon Sep 17 00:00:00 2001 From: Phillipp Glanz Date: Sun, 16 Aug 2026 23:23:00 +0200 Subject: [PATCH 07/10] feat(chart): expose the per-tenant application instance settings A tenantUi value block renders the APUS_TENANT_UI_* variables the operator reads. Off by default -- an empty host, which the operator treats as the feature being switched off entirely. The RBAC needed nothing: the operator already has deployments, services and ingresses from the hosting path. Verified rather than assumed, since a missing verb produces a clean-looking reconcile and no resources. values.schema.json rejects an apiBaseUrl ending in /api. That exact mistake already cost a debugging session once: the suffix produces /api/api/tenants, which the ingress routes to the API, which has no such route, and the security filter answers a bare 403 that reads like a missing role. NOTES.txt prints the manual Entra step, but only when a host is configured. Both directions were rendered and checked, not just the interesting one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019Bff5mpWkUnZA77jys8DiR --- .../charts/apus-operator/templates/NOTES.txt | 24 ++++++++++++ .../apus-operator/templates/deployment.yaml | 16 ++++++++ .../charts/apus-operator/values.schema.json | 28 ++++++++++++++ deploy/charts/apus-operator/values.yaml | 37 +++++++++++++++++++ 4 files changed, 105 insertions(+) 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 From f9efb8635ddfd3584c6d88dd9cff41b6db9a9b99 Mon Sep 17 00:00:00 2001 From: Phillipp Glanz Date: Sun, 16 Aug 2026 23:30:19 +0200 Subject: [PATCH 08/10] feat(console): show the redirect URIs a tenant instance needs TenantResponse gains redirectUris, straight from the status the operator writes, and the console's tenant page shows both with a copy action. This is a to-do item rather than a status readout, and the page is the only place it can be raised in time. The operator cannot register these URIs -- that needs application permissions on the app registration nobody has granted -- and a missing registration does not fail at deploy time. It fails at someone's first sign-in, at the identity provider, with nothing in this cluster to find. The person who can still act on it is the one who just created the tenant. Nothing renders for a tenant with no instance, asserted: an empty 'Redirect URIs' section reads like something failed to load. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019Bff5mpWkUnZA77jys8DiR --- .../apus/api/rest/tenant/TenantResponse.java | 9 +++ .../api/rest/tenant/TenantControllerTest.java | 37 +++++++++++ .../app/components/platform/RedirectUris.vue | 65 +++++++++++++++++++ ui/apps/console/app/pages/tenants/[name].vue | 2 + .../tests/nuxt/redirectUris.nuxt.spec.ts | 33 ++++++++++ ui/layers/core/app/utils/apiTypes.ts | 7 ++ 6 files changed, 153 insertions(+) create mode 100644 ui/apps/console/app/components/platform/RedirectUris.vue create mode 100644 ui/apps/console/tests/nuxt/redirectUris.nuxt.spec.ts 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/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[] } From e21a5dea3899e65372db29f61d336ad2867ee282 Mon Sep 17 00:00:00 2001 From: Phillipp Glanz Date: Sun, 16 Aug 2026 23:36:51 +0200 Subject: [PATCH 09/10] fix(operator): harden the tenant instance like the platform hardens the same image Verifying the quota claim against the real cluster with a server-side dry-run proved it exactly -- 'must specify requests.cpu for: ui; requests.memory for: ui' without them, admitted with them -- and the same output carried a PodSecurity 'restricted' warning that caught a defect I had introduced. The platform chart hardens its own ui Deployment: runAsNonRoot, uid 65532, RuntimeDefault seccomp, no privilege escalation, read-only root, all capabilities dropped. The per-tenant instance runs that identical image and had none of it. Only a warning on this cluster, so nothing would have broken -- it would just have been the same software running less restricted because a controller created it rather than Helm, until someone set tenant namespaces to enforce and every tenant pod started being rejected. The hardened shape passes the same dry-run with no warning at all. Also adds the k3s integration test that waits for a Pod rather than a Deployment, since only a cluster with quota admission can fail that. It sits with the module's other *IntegrationTest classes, which need Docker; it compiles but has not been executed here, and neither check nor CI runs them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019Bff5mpWkUnZA77jys8DiR --- ...26-08-16-per-tenant-app-instance-design.md | 17 +++ .../tenant/TenantUiResourceBuilder.java | 36 +++++ .../operator/OperatorIntegrationTest.java | 131 ++++++++++++++++++ .../tenant/TenantUiResourceBuilderTest.java | 23 +++ 4 files changed, 207 insertions(+) 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 index bb31d77..d51c9e2 100644 --- 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 @@ -53,6 +53,21 @@ a compute resource makes that request mandatory for every pod, and an empty limi 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. @@ -157,6 +172,8 @@ one instance among several. | --- | --- | | 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 | 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 index 27b3cb5..d98f7e0 100644 --- a/operator/src/main/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilder.java +++ b/operator/src/main/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilder.java @@ -24,11 +24,15 @@ 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; @@ -140,6 +144,7 @@ public static Deployment deployment( .withPorts(containerPort()) .withEnv(env(tenant, config)) .withResources(resources()) + .withSecurityContext(containerSecurityContext()) .withReadinessProbe(probe(tenant)) .withLivenessProbe(probe(tenant)) .build(); @@ -162,6 +167,7 @@ public static Deployment deployment( .endMetadata() .withNewSpec() .withContainers(container) + .withSecurityContext(podSecurityContext()) .endSpec() .endTemplate() .endSpec() @@ -302,6 +308,36 @@ private static Probe probe(Tenant tenant) { .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() 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/tenant/TenantUiResourceBuilderTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilderTest.java index 00b9ed2..4c24070 100644 --- a/operator/src/test/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilderTest.java +++ b/operator/src/test/java/net/onelitefeather/apus/operator/tenant/TenantUiResourceBuilderTest.java @@ -125,6 +125,29 @@ void theDeploymentDeclaresResourceRequestsOrTheQuotaWouldRejectEveryPod() { 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 From 05bb0fb422bcccb72be09fa5eee94e788dd4e999 Mon Sep 17 00:00:00 2001 From: Phillipp Glanz Date: Sun, 16 Aug 2026 23:44:16 +0200 Subject: [PATCH 10/10] docs: put a blank line between the task labels and their lists markdownlint MD032 -- twelve times, all the same shape: a **Files:**/ **Interfaces:** label followed straight by a bullet list. Verified locally with markdownlint-cli2 this time rather than by pushing and waiting. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019Bff5mpWkUnZA77jys8DiR --- .../plans/2026-08-16-per-tenant-app-instance.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 index 003bdfb..f124d9a 100644 --- a/docs/superpowers/plans/2026-08-16-per-tenant-app-instance.md +++ b/docs/superpowers/plans/2026-08-16-per-tenant-app-instance.md @@ -26,12 +26,14 @@ ### 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. @@ -235,10 +237,12 @@ git commit --no-gpg-sign -m "feat(operator): configure the per-tenant applicatio ### 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//` @@ -683,10 +687,12 @@ git commit --no-gpg-sign -m "feat(operator): build the per-tenant application in ### 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. @@ -766,10 +772,12 @@ git commit --no-gpg-sign -m "feat(operator): report the redirect URIs a tenant i ### 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. @@ -963,6 +971,7 @@ git commit --no-gpg-sign -m "feat(operator): provision an application instance p ### 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` @@ -970,6 +979,7 @@ git commit --no-gpg-sign -m "feat(operator): provision an application instance p - 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. @@ -1056,11 +1066,13 @@ git commit --no-gpg-sign -m "feat(chart): expose the per-tenant application inst ### 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.