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]: