From 150f13d1346e39d12bf54959f90a09e1d16f23c6 Mon Sep 17 00:00:00 2001 From: Darius Jahandarie Date: Sat, 5 Sep 2026 17:03:33 +0900 Subject: [PATCH] console: let self-managed instances set a display name Consoles are otherwise identical, so an operator running several Materialize instances cannot tell from a browser tab which instance a console is pointed at, and a change meant for dev is one tab away from landing on prod. Adds `spec.consoleAppearance` to the Materialize CRD: spec: consoleAppearance: displayName: prod Orchestratord copies it onto the Console resource and into the `app-config.json` it already writes for the console, so no new configuration channel and no console rollout are needed to pick it up. The console appends the name to its browser tab title. It sets the title from the app config before authenticating, so the name is there on the login screen too. `consoleAppearance` is a struct rather than a flat `consoleDisplayName` so that further per-instance appearance settings can join it without another top-level field. The field is optional and has no effect in Cloud, which serves one console for all of an organization's regions. It is excluded from the rollout hash and skipped when unset, so setting it does not roll environmentd, and adopting a version of the operator that knows about it does not roll instances that leave it unset. Tests: adds `console_appearance_does_not_affect_the_rollout_hash` in `mz-cloud-resources` and a `ConsoleAppearance` modification in `test/orchestratord/mzcompose.py` covering the passthrough into `app-config.json`, plus console unit tests for the tab title and the app-config parsing. Co-Authored-By: Claude Opus 5 --- console/src/config/AppConfig.ts | 9 +++ console/src/config/appearance.test.ts | 42 +++++++++++++ console/src/config/appearance.ts | 38 ++++++++++++ console/src/config/importAppConfig.ts | 4 ++ console/src/index.tsx | 4 ++ .../materialize_crd_descriptions_v1.json | 21 +++++++ ...materialize_crd_descriptions_v1alpha1.json | 21 +++++++ src/cloud-resources/src/crd.rs | 14 +++++ src/cloud-resources/src/crd/console.rs | 6 +- src/cloud-resources/src/crd/materialize.rs | 61 ++++++++++++++++++- src/orchestratord/src/controller/console.rs | 5 +- .../src/controller/materialize.rs | 1 + test/orchestratord/mzcompose.py | 40 ++++++++++++ 13 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 console/src/config/appearance.test.ts create mode 100644 console/src/config/appearance.ts diff --git a/console/src/config/AppConfig.ts b/console/src/config/AppConfig.ts index 6a96388b3cd1d..20f4d43349373 100644 --- a/console/src/config/AppConfig.ts +++ b/console/src/config/AppConfig.ts @@ -28,6 +28,7 @@ import { getEnvironmentdWebsocketScheme, getFronteggUrl, } from "./apiUrls"; +import { type ConsoleAppearance } from "./appearance"; import { buildConstants } from "./buildConstants"; import { getCloudRegions } from "./cloudRegions"; import { getConsoleEnvironment } from "./consoleEnvironment"; @@ -95,6 +96,10 @@ interface IBaseAppConfig { environmentdWebsocketScheme: WebsocketScheme; // Whether query retries in react-query are enabled reactQueryRetriesEnabled: boolean; + // How this instance's console distinguishes itself from the consoles of + // other instances. Never set in cloud mode, which serves one console for all + // of an organization's regions. + appearance: ConsoleAppearance | undefined; } export class CloudAppConfig implements IBaseAppConfig { @@ -200,6 +205,8 @@ export class CloudAppConfig implements IBaseAppConfig { // Whether the current environment requires user registration outside of the Console. This occurs in production // when the Console's 'sign up' button links to the Marketing site. requiresExternalRegistration = this.#consoleEnvironment === "production"; + + appearance = undefined; } export class SelfManagedAppConfig implements IBaseAppConfig { @@ -209,6 +216,8 @@ export class SelfManagedAppConfig implements IBaseAppConfig { balancerdDnsNames: string[] | undefined = appConfigJson.balancerdDnsNames; + appearance: ConsoleAppearance | undefined = appConfigJson.appearance; + environmentdScheme = getEnvironmentdScheme({ buildConstants, isLocalImpersonation: false, diff --git a/console/src/config/appearance.test.ts b/console/src/config/appearance.test.ts new file mode 100644 index 0000000000000..b5ecc083f15b2 --- /dev/null +++ b/console/src/config/appearance.test.ts @@ -0,0 +1,42 @@ +// Copyright Materialize, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +import { documentTitle, parseConsoleAppearance } from "./appearance"; + +describe("documentTitle", () => { + it("names the instance when it has a display name", () => { + expect(documentTitle({ displayName: "prod" })).toEqual( + "Materialize Console · prod", + ); + }); + + it("falls back to the plain title", () => { + expect(documentTitle(undefined)).toEqual("Materialize Console"); + expect(documentTitle({})).toEqual("Materialize Console"); + }); +}); + +describe("parseConsoleAppearance", () => { + it("returns undefined when unconfigured", () => { + expect(parseConsoleAppearance(undefined)).toBeUndefined(); + expect(parseConsoleAppearance(null)).toBeUndefined(); + }); + + it("keeps a display name", () => { + expect(parseConsoleAppearance({ displayName: "prod" })).toEqual({ + displayName: "prod", + }); + }); + + it("treats an empty display name as unset", () => { + expect(parseConsoleAppearance({ displayName: "" })).toEqual({ + displayName: undefined, + }); + }); +}); diff --git a/console/src/config/appearance.ts b/console/src/config/appearance.ts new file mode 100644 index 0000000000000..3feaf1ac68a80 --- /dev/null +++ b/console/src/config/appearance.ts @@ -0,0 +1,38 @@ +// Copyright Materialize, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +/** + * @module + * Per-instance appearance, set on the Materialize resource and delivered to + * the browser in app-config.json. Self-managed only, since Cloud serves one + * console for all of a user's regions. + */ + +export interface ConsoleAppearance { + /** A short name for the instance, such as "dev" or "prod". */ + displayName?: string; +} + +const BASE_DOCUMENT_TITLE = "Materialize Console"; + +/** Builds the browser tab title, which names the instance when configured. */ +export const documentTitle = (appearance: ConsoleAppearance | undefined) => + appearance?.displayName + ? `${BASE_DOCUMENT_TITLE} · ${appearance.displayName}` + : BASE_DOCUMENT_TITLE; + +/** Reads the appearance out of app-config.json. */ +export const parseConsoleAppearance = ( + appearance: { displayName?: string } | null | undefined, +): ConsoleAppearance | undefined => { + if (!appearance) { + return undefined; + } + return { displayName: appearance.displayName || undefined }; +}; diff --git a/console/src/config/importAppConfig.ts b/console/src/config/importAppConfig.ts index 54027f15f36ea..2de59f3b12013 100644 --- a/console/src/config/importAppConfig.ts +++ b/console/src/config/importAppConfig.ts @@ -8,6 +8,7 @@ // by the Apache License, Version 2.0. import { type SelfManagedAuthMode } from "./AppConfig"; +import { type ConsoleAppearance, parseConsoleAppearance } from "./appearance"; const DEFAULT_APP_CONFIG = { auth: { @@ -35,6 +36,7 @@ export function importAppConfig(): { mode: SelfManagedAuthMode; }; balancerdDnsNames?: string[]; + appearance?: ConsoleAppearance; } { if (process.env.NODE_ENV === "test") { return DEFAULT_APP_CONFIG; @@ -45,9 +47,11 @@ export function importAppConfig(): { mode: SelfManagedAuthMode; }; balancerd_dns_names?: string[]; + appearance?: { displayName?: string }; }; return { auth: json.auth, balancerdDnsNames: json.balancerd_dns_names, + appearance: parseConsoleAppearance(json.appearance), }; } diff --git a/console/src/index.tsx b/console/src/index.tsx index 5bc7fd0d6e91c..73810c9fd4ffa 100644 --- a/console/src/index.tsx +++ b/console/src/index.tsx @@ -22,10 +22,14 @@ import "~/sentry"; import React from "react"; import { createRoot } from "react-dom/client"; +import { appConfig } from "~/config/AppConfig"; +import { documentTitle } from "~/config/appearance"; import { App } from "~/platform/App"; import { addChunkLoadErrorListener } from "./utils/chunkLoadErrorHandler"; +document.title = documentTitle(appConfig.appearance); + const rootEl = document.createElement("div"); document.body.appendChild(rootEl); const root = createRoot(rootEl); diff --git a/doc/user/data/self_managed/materialize_crd_descriptions_v1.json b/doc/user/data/self_managed/materialize_crd_descriptions_v1.json index 65d928a44eaa1..2f22f9856687a 100644 --- a/doc/user/data/self_managed/materialize_crd_descriptions_v1.json +++ b/doc/user/data/self_managed/materialize_crd_descriptions_v1.json @@ -50,6 +50,14 @@ "required": false, "deprecated": false }, + { + "name": "consoleAppearance", + "type": "ConsoleAppearance", + "description": "Appearance overrides for this instance's console.\n\nThis field is excluded from the rollout hash and changes will not trigger a rollout.", + "default": null, + "required": false, + "deprecated": false + }, { "name": "consoleExternalCertificateSpec", "type": "MaterializeCertSpec", @@ -331,6 +339,19 @@ } ] ], + [ + "ConsoleAppearance", + [ + { + "name": "displayName", + "type": "String", + "description": "A short name for this instance, such as `dev` or `prod`. The console\nappends it to its browser tab title.", + "default": null, + "required": false, + "deprecated": false + } + ] + ], [ "io.k8s.api.core.v1.ResourceRequirements", [ diff --git a/doc/user/data/self_managed/materialize_crd_descriptions_v1alpha1.json b/doc/user/data/self_managed/materialize_crd_descriptions_v1alpha1.json index 1a280f1264705..eb7c32683bd98 100644 --- a/doc/user/data/self_managed/materialize_crd_descriptions_v1alpha1.json +++ b/doc/user/data/self_managed/materialize_crd_descriptions_v1alpha1.json @@ -50,6 +50,14 @@ "required": false, "deprecated": false }, + { + "name": "consoleAppearance", + "type": "ConsoleAppearance", + "description": "Appearance overrides for this instance's console.", + "default": null, + "required": false, + "deprecated": false + }, { "name": "consoleExternalCertificateSpec", "type": "MaterializeCertSpec", @@ -355,6 +363,19 @@ } ] ], + [ + "ConsoleAppearance", + [ + { + "name": "displayName", + "type": "String", + "description": "A short name for this instance, such as `dev` or `prod`. The console\nappends it to its browser tab title.", + "default": null, + "required": false, + "deprecated": false + } + ] + ], [ "io.k8s.api.core.v1.ResourceRequirements", [ diff --git a/src/cloud-resources/src/crd.rs b/src/cloud-resources/src/crd.rs index b1a828d3d4d91..8fe9b1154009e 100644 --- a/src/cloud-resources/src/crd.rs +++ b/src/cloud-resources/src/crd.rs @@ -68,6 +68,20 @@ pub struct MaterializeCertSpec { pub private_key_size: Option, } +/// Appearance overrides for an instance's console. +/// +/// Consoles are otherwise identical, so an operator running several +/// Materialize instances cannot tell from a browser tab which instance a +/// console is pointed at. +#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ConsoleAppearance { + /// A short name for this instance, such as `dev` or `prod`. The console + /// appends it to its browser tab title. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, +} + pub trait ManagedResource: Resource + Sized { fn default_labels(&self) -> BTreeMap { BTreeMap::new() diff --git a/src/cloud-resources/src/crd/console.rs b/src/cloud-resources/src/crd/console.rs index 5b8f0e7403e4a..e89d50bd601c1 100644 --- a/src/cloud-resources/src/crd/console.rs +++ b/src/cloud-resources/src/crd/console.rs @@ -16,7 +16,7 @@ use kube::{CustomResource, Resource, ResourceExt}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::crd::{ManagedResource, MaterializeCertSpec, new_resource_id}; +use crate::crd::{ConsoleAppearance, ManagedResource, MaterializeCertSpec, new_resource_id}; use mz_server_core::listeners::AuthenticatorKind; pub mod v1alpha1 { @@ -88,6 +88,10 @@ pub mod v1alpha1 { #[serde(default)] pub authenticator_kind: AuthenticatorKind, + /// Appearance overrides for this console. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub appearance: Option, + // This can be set to override the randomly chosen resource id pub resource_id: Option, } diff --git a/src/cloud-resources/src/crd/materialize.rs b/src/cloud-resources/src/crd/materialize.rs index 714b1c43963c8..a4a32b75a0e5d 100644 --- a/src/cloud-resources/src/crd/materialize.rs +++ b/src/cloud-resources/src/crd/materialize.rs @@ -32,7 +32,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use uuid::Uuid; -use crate::crd::{ManagedResource, MaterializeCertSpec, new_resource_id}; +use crate::crd::{ConsoleAppearance, ManagedResource, MaterializeCertSpec, new_resource_id}; use mz_server_core::listeners::AuthenticatorKind; pub const LAST_KNOWN_ACTIVE_GENERATION_ANNOTATION: &str = @@ -174,6 +174,9 @@ pub mod v1alpha1 { pub balancerd_replicas: Option, /// Number of console pods to create. pub console_replicas: Option, + /// Appearance overrides for this instance's console. + #[serde(skip_serializing_if = "Option::is_none")] + pub console_appearance: Option, /// Name of the kubernetes service account to use. /// If not set, we will create one with the same name as this Materialize object. @@ -862,6 +865,7 @@ pub mod v1alpha1 { console_resource_requirements: value.spec.console_resource_requirements, balancerd_replicas: value.spec.balancerd_replicas, console_replicas: value.spec.console_replicas, + console_appearance: value.spec.console_appearance, service_account_name: value.spec.service_account_name, service_account_annotations: value.spec.service_account_annotations, service_account_labels: value.spec.service_account_labels, @@ -987,6 +991,12 @@ pub mod v1alpha1 { with = "double_option", skip_serializing_if = "Option::is_none" )] + pub console_appearance: PartialField, + #[serde( + default, + with = "double_option", + skip_serializing_if = "Option::is_none" + )] pub service_account_name: PartialField, #[serde( default, @@ -1182,6 +1192,7 @@ pub mod v1alpha1 { console_resource_requirements, balancerd_replicas, console_replicas, + console_appearance, service_account_name, service_account_annotations, service_account_labels, @@ -1216,6 +1227,7 @@ pub mod v1alpha1 { console_resource_requirements: present_opt(console_resource_requirements), balancerd_replicas: present_opt(balancerd_replicas), console_replicas: present_opt(console_replicas), + console_appearance: present_opt(console_appearance), service_account_name: present_opt(service_account_name), service_account_annotations: present_opt(service_account_annotations), service_account_labels: present_opt(service_account_labels), @@ -1301,6 +1313,7 @@ pub mod v1alpha1 { console_resource_requirements, balancerd_replicas, console_replicas, + console_appearance, service_account_name, service_account_annotations, service_account_labels, @@ -1332,6 +1345,7 @@ pub mod v1alpha1 { console_resource_requirements, balancerd_replicas, console_replicas, + console_appearance, service_account_name, service_account_annotations, service_account_labels, @@ -1467,6 +1481,11 @@ pub mod v1 { /// /// This field is excluded from the rollout hash and changes will not trigger a rollout. pub console_replicas: Option, + /// Appearance overrides for this instance's console. + /// + /// This field is excluded from the rollout hash and changes will not trigger a rollout. + #[serde(skip_serializing_if = "Option::is_none")] + pub console_appearance: Option, /// Name of the kubernetes service account to use. /// If not set, we will create one with the same name as this Materialize object. @@ -1603,6 +1622,7 @@ pub mod v1 { console_resource_requirements: None, balancerd_replicas: None, console_replicas: None, + console_appearance: None, service_account_name: self.spec.service_account_name.clone(), service_account_annotations: self.spec.service_account_annotations.clone(), service_account_labels: self.spec.service_account_labels.clone(), @@ -2079,6 +2099,7 @@ pub mod v1 { console_resource_requirements: value.spec.console_resource_requirements, balancerd_replicas: value.spec.balancerd_replicas, console_replicas: value.spec.console_replicas, + console_appearance: value.spec.console_appearance, service_account_name: value.spec.service_account_name, service_account_annotations, service_account_labels: value.spec.service_account_labels, @@ -2227,6 +2248,12 @@ pub mod v1 { with = "double_option", skip_serializing_if = "Option::is_none" )] + pub console_appearance: PartialField, + #[serde( + default, + with = "double_option", + skip_serializing_if = "Option::is_none" + )] pub service_account_name: PartialField, #[serde( default, @@ -2403,6 +2430,7 @@ pub mod v1 { console_resource_requirements, balancerd_replicas, console_replicas, + console_appearance, service_account_name, service_account_annotations, service_account_labels, @@ -2434,6 +2462,7 @@ pub mod v1 { console_resource_requirements: present_opt(console_resource_requirements), balancerd_replicas: present_opt(balancerd_replicas), console_replicas: present_opt(console_replicas), + console_appearance: present_opt(console_appearance), service_account_name: present_opt(service_account_name), service_account_annotations: present_opt(service_account_annotations), service_account_labels: present_opt(service_account_labels), @@ -2516,6 +2545,7 @@ pub mod v1 { console_resource_requirements, balancerd_replicas, console_replicas, + console_appearance, service_account_name, service_account_annotations, service_account_labels, @@ -2558,6 +2588,7 @@ pub mod v1 { console_resource_requirements, balancerd_replicas, console_replicas, + console_appearance, service_account_name, service_account_annotations, service_account_labels, @@ -2793,6 +2824,7 @@ mod tests { use super::v1alpha1::{Materialize, MaterializeSpec, MaterializeStatus}; use super::{DEFAULT_ROLLOUT_REQUEST_TIMEOUT, FORCE_ROLLOUT_ANNOTATION, RolloutRequestTimeout}; + use crate::crd::ConsoleAppearance; #[mz_ore::test] #[cfg_attr(miri, ignore)] // can't call foreign function `sha256_compress` on OS `linux` @@ -3432,4 +3464,31 @@ mod tests { } assert!(!value.as_object().unwrap().contains_key("status")); } + + #[mz_ore::test] + #[cfg_attr(miri, ignore)] // can't call foreign function `sha256_compress` on OS `linux` + fn console_appearance_does_not_affect_the_rollout_hash() { + // Appearance reaches only the console deployment, so it must not roll + // environmentd. + let mut mz = super::v1::Materialize { + spec: super::v1::MaterializeSpec { + environmentd_image_ref: "materialize/environmentd:v26.0.0".to_owned(), + ..Default::default() + }, + metadata: ObjectMeta::default(), + status: None, + }; + + // An unset appearance serializes away entirely, so instances that + // don't configure one hash the same as they did before the field + // existed and are not rolled by adopting a new operator. + let spec = serde_json::to_value(&mz.spec).unwrap(); + assert!(!spec.as_object().unwrap().contains_key("consoleAppearance")); + + let hash = mz.generate_rollout_hash(); + mz.spec.console_appearance = Some(ConsoleAppearance { + display_name: Some("prod".to_owned()), + }); + assert_eq!(mz.generate_rollout_hash(), hash); + } } diff --git a/src/orchestratord/src/controller/console.rs b/src/orchestratord/src/controller/console.rs index 173bef895c2e1..c6b45129e62bd 100644 --- a/src/orchestratord/src/controller/console.rs +++ b/src/orchestratord/src/controller/console.rs @@ -43,7 +43,7 @@ use crate::{ tls::{DefaultCertificateSpecs, create_certificate, issuer_ref_defined}, }; use mz_cloud_resources::crd::{ - ManagedResource, + ConsoleAppearance, ManagedResource, console::v1alpha1::{Console, HttpConnectionScheme}, generated::cert_manager::certificates::{Certificate, CertificatePrivateKeyAlgorithm}, }; @@ -77,6 +77,8 @@ struct AppConfig { auth: AppConfigAuth, #[serde(default, skip_serializing_if = "Option::is_none")] balancerd_dns_names: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + appearance: Option, } #[derive(Serialize)] @@ -239,6 +241,7 @@ impl Context { auth: AppConfigAuth { mode: console.spec.authenticator_kind, }, + appearance: console.spec.appearance.clone(), }) .expect("known valid"); ConfigMap { diff --git a/src/orchestratord/src/controller/materialize.rs b/src/orchestratord/src/controller/materialize.rs index 177b339834b9f..068e0a8795d26 100644 --- a/src/orchestratord/src/controller/materialize.rs +++ b/src/orchestratord/src/controller/materialize.rs @@ -865,6 +865,7 @@ impl k8s_controller::Context for Context { ), resource_requirements: mz.spec.console_resource_requirements.clone(), replicas: Some(mz.console_replicas()), + appearance: mz.spec.console_appearance.clone(), external_certificate_spec: mz.spec.console_external_certificate_spec.clone(), pod_annotations: mz.spec.pod_annotations.clone(), pod_labels: mz.spec.pod_labels.clone(), diff --git a/test/orchestratord/mzcompose.py b/test/orchestratord/mzcompose.py index 1b5a88b08d58c..84b4eb585aaef 100644 --- a/test/orchestratord/mzcompose.py +++ b/test/orchestratord/mzcompose.py @@ -1744,6 +1744,46 @@ def check() -> None: retry(check, 360) +class ConsoleAppearance(Modification): + # The operator copies the Materialize CR's `consoleAppearance` into the + # console's `app-config.json`, which is how the console learns which + # instance it is pointed at. + APPEARANCE = {"displayName": "prod"} + + @classmethod + def values(cls, version: MzVersion) -> list[Any]: + return [None, cls.APPEARANCE] + + @classmethod + def default(cls) -> Any: + return None + + def modify(self, definition: dict[str, Any]) -> None: + if self.value is not None: + definition["materialize"]["spec"]["consoleAppearance"] = self.value + + def validate(self, mods: dict[type[Modification], Any]) -> None: + # `consoleAppearance` was added in v26.41; older orchestratord builds + # drop the field. + if MzVersion.parse_mz(mods[EnvironmentdImageRef]) < MzVersion.parse_mz( + "v26.41.0-dev.0" + ): + return + # Without a console there's no app config to inspect. + if not mods[ConsoleEnabled]: + return + + def check() -> None: + app_config = get_console_app_config() + actual = app_config.get("appearance") + assert ( + actual == self.value + ), f"Expected appearance {self.value}, but got {actual}: {app_config}" + + # The console is reconciled last and the configmap update is async. + retry(check, 360) + + class RecommendedK8sLabels(Modification): @classmethod def values(cls, version: MzVersion) -> list[Any]: