Skip to main content

mz_orchestratord/controller/materialize/
teleport.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Labels and annotations that let the `teleport-kube-agent` already
11//! deployed in every cluster discover the environmentd Service and
12//! register it as a Teleport `app`, via Kubernetes annotation-based
13//! auto-discovery.
14//!
15//! This is Phase 1 of moving `environmentd` Teleport registration off of
16//! `environment-controller`'s `tctl`-based flow. It registers the `app`
17//! only; the `db` stays on `environment-controller` until Phase 2 installs
18//! the Teleport Kubernetes Operator.
19
20use std::collections::BTreeMap;
21
22use data_encoding::BASE32_NOPAD;
23use k8s_openapi::api::core::v1::Service;
24use kube::ResourceExt;
25use mz_cloud_provider::CloudProvider;
26use mz_cloud_resources::crd::materialize::v1alpha1::Materialize;
27use serde::Serialize;
28use tracing::error;
29use uuid::Uuid;
30
31use super::Config;
32
33/// The named environmentd Service port to register.
34///
35/// NOTE: this is a security control, not tidiness. Without it the agent reads
36/// every port on the Service, and its protocol detection matches the port name
37/// before it pings anything, so the port named `https` registers as an app.
38/// That is the customer-facing endpoint, and every app the agent builds from
39/// one Service inherits that Service's `teleport.dev/app-rewrite`, so it would
40/// arrive carrying the `mz_support` header.
41const TELEPORT_APP_PORT: &str = "internal-http";
42
43/// The internal HTTP endpoint is plain HTTP, not HTTPS. Set explicitly rather
44/// than relying on Teleport's port-name heuristic to guess it.
45///
46/// NOTE: the agent applies this to every port it selects, so it is only safe
47/// alongside `TELEPORT_APP_PORT`. On its own it would force `http` on all four
48/// ports and register four apps.
49const TELEPORT_APP_PROTOCOL: &str = "http";
50
51const TELEPORT_APP_DESCRIPTION: &str = "Environmentd Internal HTTP API";
52
53/// Forces the support database user on internal HTTP requests reaching the
54/// registered app. This is a security control, not a convenience: it is
55/// what stops a support session from picking a different database role.
56const TELEPORT_REWRITE_HEADER_NAME: &str = "X-Materialize-User";
57const TELEPORT_REWRITE_HEADER_VALUE: &str = "mz_support";
58
59/// The settings that the `--teleport-*` flags provide.
60#[derive(Clone)]
61pub struct TeleportConfig {
62    pub stack_type: String,
63    pub cluster_name: String,
64}
65
66#[derive(Serialize)]
67struct RewriteHeader {
68    name: String,
69    value: String,
70}
71
72#[derive(Serialize)]
73struct Rewrite {
74    headers: Vec<RewriteHeader>,
75}
76
77/// The trailing ordinal of a Materialize resource name, named
78/// `environment-{org_uuid}-{ordinal}`.
79///
80/// `environment-controller` derives the ordinal the same way, in `ordinal()`
81/// in the `cloud` repo (`src/environment/src/util.rs`). The two must agree,
82/// because the un-prefixed form of the name this module builds has to equal
83/// the one `environment-controller` registers.
84fn ordinal(resource_name: &str) -> &str {
85    resource_name.split('-').next_back().unwrap_or("0")
86}
87
88/// The Teleport `app` resource name: `mz-{cloud_provider}-{region}-{base32(org_uuid)}-{ordinal}`.
89///
90/// The `mz-` prefix lets this registration coexist with the `app`
91/// `environment-controller` registers under the un-prefixed name. Add the
92/// prefix only here, never in `Materialize::environment_id`, which other
93/// consumers (the `--environment-id` flag, KMS key aliases) rely on staying
94/// stable.
95///
96/// Returns an error when the result is not a valid DNS-1035 label, which
97/// Teleport requires. A long enough cloud provider and region can exceed the
98/// 63-character limit: `generic` plus a 23-character region leaves room for a
99/// single-digit ordinal and no more.
100fn teleport_app_name(
101    cloud_provider: CloudProvider,
102    region: &str,
103    environment_id: Uuid,
104    ordinal: &str,
105) -> Result<String, String> {
106    let name = format!(
107        "mz-{}-{}-{}-{}",
108        cloud_provider,
109        region,
110        BASE32_NOPAD
111            .encode(environment_id.as_bytes())
112            .to_lowercase(),
113        ordinal,
114    );
115    match dns1035_label_error(&name) {
116        Some(reason) => Err(format!("{name:?} is not a valid DNS-1035 label: {reason}")),
117        None => Ok(name),
118    }
119}
120
121/// Why `name` is not a valid DNS-1035 label, or `None` when it is one.
122///
123/// Mirrors `validation.IsDNS1035Label`, which Teleport runs on the
124/// `teleport.dev/name` annotation. A name that fails it registers nothing at
125/// all: the agent logs one warning and drops the whole Service, so the
126/// environment never appears in Teleport.
127fn dns1035_label_error(name: &str) -> Option<&'static str> {
128    if name.len() > 63 {
129        return Some("longer than 63 characters");
130    }
131    if !name.starts_with(|c: char| c.is_ascii_lowercase()) {
132        return Some("does not start with a lowercase letter");
133    }
134    if !name.ends_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()) {
135        return Some("does not end with a lowercase letter or digit");
136    }
137    if !name
138        .chars()
139        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
140    {
141        return Some("contains a character outside [-a-z0-9]");
142    }
143    None
144}
145
146/// Adds the Teleport discovery labels and annotations to `service` when
147/// `--enable-teleport-registration` is set. A no-op otherwise, which is the
148/// rollback path: without the flag, this function changes nothing about the
149/// Service.
150///
151/// A Service the agent would reject is left unannotated rather than annotated
152/// and silently dropped, so the reason reaches our own logs instead of only
153/// the agent's.
154pub(super) fn apply_teleport_registration(
155    config: &Config,
156    mz: &Materialize,
157    service: &mut Service,
158) {
159    let Some(teleport) = &config.teleport else {
160        return;
161    };
162
163    let resource_name = mz.name_unchecked();
164    let app_name = match teleport_app_name(
165        config.cloud_provider,
166        &config.region,
167        mz.spec.environment_id,
168        ordinal(&resource_name),
169    ) {
170        Ok(app_name) => app_name,
171        Err(reason) => {
172            error!(%resource_name, "skipping Teleport registration: {reason}");
173            return;
174        }
175    };
176
177    let labels = service.metadata.labels.get_or_insert_with(BTreeMap::new);
178    // NOTE: `materialize.cloud/app` does double duty. It identifies the Service, and
179    // it is the only label the agent's discovery matcher selects on, so writing it
180    // only when `--enable-teleport-registration` is set is what stops the agent
181    // registering anything on a cluster where the matcher is deployed but the flag
182    // is not. Moving it to `ManagedResource::default_labels` would make it
183    // unconditional and register every environmentd Service under a name the agent
184    // invents, with no rewrite header. Change `teleport_discovery_kube_apps` in the
185    // cloud repo's `infra/cluster/environment.py` to select on a dedicated gate
186    // label first.
187    labels.insert(
188        "materialize.cloud/app".to_string(),
189        mz.environmentd_app_name(),
190    );
191    labels.insert("stack-type".to_string(), teleport.stack_type.clone());
192    labels.insert("cluster".to_string(), teleport.cluster_name.clone());
193
194    let rewrite = Rewrite {
195        headers: vec![RewriteHeader {
196            name: TELEPORT_REWRITE_HEADER_NAME.to_string(),
197            value: TELEPORT_REWRITE_HEADER_VALUE.to_string(),
198        }],
199    };
200    let annotations = service
201        .metadata
202        .annotations
203        .get_or_insert_with(BTreeMap::new);
204    annotations.insert("teleport.dev/name".to_string(), app_name);
205    annotations.insert(
206        "teleport.dev/port".to_string(),
207        TELEPORT_APP_PORT.to_string(),
208    );
209    annotations.insert(
210        "teleport.dev/protocol".to_string(),
211        TELEPORT_APP_PROTOCOL.to_string(),
212    );
213    annotations.insert(
214        "teleport.dev/description".to_string(),
215        TELEPORT_APP_DESCRIPTION.to_string(),
216    );
217    annotations.insert(
218        "teleport.dev/app-rewrite".to_string(),
219        // Teleport's reference documents this annotation as YAML, but its decoder
220        // (Kubernetes' YAMLOrJSONDecoder) dispatches on a leading `{` and takes
221        // JSON too. JSON keeps significant whitespace out of an annotation value.
222        serde_json::to_string(&rewrite).expect("Rewrite has no non-serializable fields"),
223    );
224}
225
226#[cfg(test)]
227mod tests {
228    use uuid::uuid;
229
230    use super::*;
231
232    const ORG: Uuid = uuid!("01d730c7-5a61-4d7a-9f3c-63ed01f34ebf");
233
234    // The expected value is `environment_id_base32_from_env`'s own fixture in
235    // the `cloud` repo (`src/environment/src/util.rs`), with the prefix added.
236    #[mz_ore::test]
237    fn app_name_matches_the_base32_convention() {
238        assert_eq!(
239            teleport_app_name(
240                CloudProvider::Local,
241                "kind",
242                ORG,
243                ordinal("environment-01d730c7-5a61-4d7a-9f3c-63ed01f34ebf-0"),
244            ),
245            Ok("mz-local-kind-ahltbr22mfgxvhz4mpwqd42ox4-0".to_string()),
246        );
247    }
248
249    #[mz_ore::test]
250    fn app_name_carries_a_non_zero_ordinal() {
251        assert_eq!(
252            teleport_app_name(
253                CloudProvider::Local,
254                "kind",
255                ORG,
256                ordinal("environment-01d730c7-5a61-4d7a-9f3c-63ed01f34ebf-12"),
257            ),
258            Ok("mz-local-kind-ahltbr22mfgxvhz4mpwqd42ox4-12".to_string()),
259        );
260    }
261
262    // The longest region in `infra/` is 14 characters, so every region we
263    // deploy today clears the limit with room to spare.
264    #[mz_ore::test]
265    fn app_name_fits_the_longest_region_we_deploy() {
266        let name = teleport_app_name(CloudProvider::Aws, "ap-southeast-2", ORG, "0")
267            .expect("valid DNS-1035 label");
268        assert_eq!(name.len(), 50);
269    }
270
271    #[mz_ore::test]
272    fn app_name_rejects_a_label_over_63_characters() {
273        assert_eq!(
274            teleport_app_name(
275                CloudProvider::Generic,
276                "northamerica-northeast2",
277                ORG,
278                "123",
279            ),
280            Err(
281                "\"mz-generic-northamerica-northeast2-ahltbr22mfgxvhz4mpwqd42ox4-123\" \
282                 is not a valid DNS-1035 label: longer than 63 characters"
283                    .to_string()
284            ),
285        );
286    }
287
288    #[mz_ore::test]
289    fn app_rewrite_serializes_to_one_json_line() {
290        let rewrite = Rewrite {
291            headers: vec![RewriteHeader {
292                name: TELEPORT_REWRITE_HEADER_NAME.to_string(),
293                value: TELEPORT_REWRITE_HEADER_VALUE.to_string(),
294            }],
295        };
296        assert_eq!(
297            serde_json::to_string(&rewrite).expect("serializable"),
298            r#"{"headers":[{"name":"X-Materialize-User","value":"mz_support"}]}"#,
299        );
300    }
301
302    #[mz_ore::test]
303    fn dns1035_label_error_matches_the_kubernetes_rules() {
304        assert_eq!(dns1035_label_error("mz-aws-us-east-1-abc-0"), None);
305        assert_eq!(
306            dns1035_label_error("0-leading-digit"),
307            Some("does not start with a lowercase letter"),
308        );
309        assert_eq!(
310            dns1035_label_error("trailing-dash-"),
311            Some("does not end with a lowercase letter or digit"),
312        );
313        assert_eq!(
314            dns1035_label_error("mz-AWS-us-east-1"),
315            Some("contains a character outside [-a-z0-9]"),
316        );
317    }
318}