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}