1use k8s_controller::TraceMetadata;
11use k8s_openapi::{
12 api::{
13 apps::v1::{Deployment, DeploymentSpec},
14 core::v1::{
15 Affinity, Capabilities, ConfigMap, ConfigMapVolumeSource, Container, ContainerPort,
16 EnvVar, HTTPGetAction, KeyToPath, PodSecurityContext, PodSpec, PodTemplateSpec, Probe,
17 ResourceRequirements, SeccompProfile, SecretVolumeSource, SecurityContext, Service,
18 ServicePort, ServiceSpec, Toleration, Volume, VolumeMount,
19 },
20 networking::v1::{
21 IPBlock, NetworkPolicy, NetworkPolicyIngressRule, NetworkPolicyPeer, NetworkPolicyPort,
22 NetworkPolicySpec,
23 },
24 },
25 apimachinery::pkg::{
26 apis::meta::v1::{Condition, LabelSelector, Time},
27 util::intstr::IntOrString,
28 },
29 jiff::Timestamp,
30};
31use kube::{
32 Api, Client, Resource, ResourceExt,
33 api::{DeleteParams, ObjectMeta, PostParams},
34 runtime::{conditions::is_deployment_completed, controller::Action, wait::await_condition},
35};
36use maplit::btreemap;
37use serde::Serialize;
38use tracing::{trace, warn};
39
40use crate::{
41 Error,
42 k8s::{apply_resource, get_resource, recommended_k8s_labels, replace_resource},
43 tls::{DefaultCertificateSpecs, create_certificate, issuer_ref_defined},
44};
45use mz_cloud_resources::crd::{
46 ManagedResource,
47 console::v1alpha1::{Console, HttpConnectionScheme},
48 generated::cert_manager::certificates::{Certificate, CertificatePrivateKeyAlgorithm},
49};
50use mz_orchestrator_kubernetes::KubernetesImagePullPolicy;
51use mz_ore::{cli::KeyValueArg, instrument};
52use mz_server_core::listeners::AuthenticatorKind;
53
54#[derive(Clone)]
55pub struct Config {
56 pub enable_security_context: bool,
57 pub enable_prometheus_scrape_annotations: bool,
58
59 pub image_pull_policy: KubernetesImagePullPolicy,
60 pub scheduler_name: Option<String>,
61 pub console_node_selector: Vec<KeyValueArg<String, String>>,
62 pub console_affinity: Option<Affinity>,
63 pub console_tolerations: Option<Vec<Toleration>>,
64 pub console_default_resources: Option<ResourceRequirements>,
65 pub network_policies_ingress_enabled: bool,
66 pub network_policies_ingress_cidrs: Vec<String>,
67
68 pub default_certificate_specs: DefaultCertificateSpecs,
69
70 pub console_http_port: u16,
71 pub balancerd_http_port: u16,
72}
73
74#[derive(Serialize)]
75struct AppConfig {
76 version: String,
77 auth: AppConfigAuth,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 balancerd_dns_names: Option<Vec<String>>,
80}
81
82#[derive(Serialize)]
83struct AppConfigAuth {
84 mode: AuthenticatorKind,
85}
86
87pub struct Context {
88 config: Config,
89}
90
91impl Context {
92 pub fn new(config: Config) -> Self {
93 Self { config }
94 }
95
96 async fn sync_deployment_status(
97 &self,
98 client: &Client,
99 console: &Console,
100 ) -> Result<(), Error> {
101 let namespace = console.namespace();
102 let console_api: Api<Console> = Api::namespaced(client.clone(), &namespace);
103 let deployment_api: Api<Deployment> = Api::namespaced(client.clone(), &namespace);
104
105 let Some(deployment) = get_resource(&deployment_api, &console.deployment_name()).await?
106 else {
107 return Ok(());
108 };
109
110 let Some(deployment_conditions) = &deployment
111 .status
112 .as_ref()
113 .and_then(|status| status.conditions.as_ref())
114 else {
115 return Ok(());
118 };
119
120 let ready = deployment_conditions
121 .iter()
122 .any(|condition| condition.type_ == "Available" && condition.status == "True");
123 let ready_str = if ready { "True" } else { "False" };
124
125 let mut status = console.status.clone().unwrap();
126 if status
127 .conditions
128 .iter()
129 .any(|condition| condition.type_ == "Ready" && condition.status == ready_str)
130 {
131 return Ok(());
135 }
136
137 status.conditions = vec![Condition {
138 type_: "Ready".to_string(),
139 status: ready_str.to_string(),
140 last_transition_time: Time(Timestamp::now()),
141 message: format!(
142 "console deployment is{} ready",
143 if ready { "" } else { " not" }
144 ),
145 observed_generation: None,
146 reason: "DeploymentStatus".to_string(),
147 }];
148 let mut new_console = console.clone();
149 new_console.status = Some(status);
150
151 console_api
152 .replace_status(
153 &console.name_unchecked(),
154 &PostParams::default(),
155 &new_console,
156 )
157 .await?;
158
159 Ok(())
160 }
161
162 fn create_network_policies(&self, console: &Console) -> Vec<NetworkPolicy> {
163 let mut network_policies = Vec::new();
164 if self.config.network_policies_ingress_enabled {
165 let console_label_selector = LabelSelector {
166 match_labels: Some(
167 console
168 .default_labels()
169 .into_iter()
170 .chain([("materialize.cloud/app".to_owned(), console.app_name())])
171 .collect(),
172 ),
173 ..Default::default()
174 };
175 network_policies.extend([NetworkPolicy {
176 metadata: console.managed_resource_meta(console.name_prefixed("console-ingress")),
177 spec: Some(NetworkPolicySpec {
178 ingress: Some(vec![NetworkPolicyIngressRule {
179 from: Some(
180 self.config
181 .network_policies_ingress_cidrs
182 .iter()
183 .map(|cidr| NetworkPolicyPeer {
184 ip_block: Some(IPBlock {
185 cidr: cidr.to_owned(),
186 except: None,
187 }),
188 ..Default::default()
189 })
190 .collect(),
191 ),
192 ports: Some(vec![NetworkPolicyPort {
193 port: Some(IntOrString::Int(self.config.console_http_port.into())),
194 protocol: Some("TCP".to_string()),
195 ..Default::default()
196 }]),
197 ..Default::default()
198 }]),
199 pod_selector: Some(console_label_selector),
200 policy_types: Some(vec!["Ingress".to_owned()]),
201 ..Default::default()
202 }),
203 }]);
204 }
205 network_policies
206 }
207
208 fn create_console_external_certificate(
209 &self,
210 console: &Console,
211 ) -> anyhow::Result<Option<Certificate>> {
212 create_certificate(
213 self.config
214 .default_certificate_specs
215 .console_external
216 .clone(),
217 console,
218 console.spec.external_certificate_spec.clone(),
219 console.external_certificate_name(),
220 console.external_certificate_secret_name(),
221 None,
222 CertificatePrivateKeyAlgorithm::Ecdsa,
223 Some(256),
224 )
225 }
226
227 fn create_console_app_configmap_object(&self, console: &Console) -> ConfigMap {
228 let balancerd_dns_names = console.spec.balancerd.dns_names.clone();
229 let version: String = console
230 .spec
231 .console_image_ref
232 .rsplitn(2, ':')
233 .next()
234 .expect("at least one chunk, even if empty")
235 .to_owned();
236 let app_config_json = serde_json::to_string(&AppConfig {
237 version,
238 balancerd_dns_names,
239 auth: AppConfigAuth {
240 mode: console.spec.authenticator_kind,
241 },
242 })
243 .expect("known valid");
244 ConfigMap {
245 binary_data: None,
246 data: Some(btreemap! {
247 "app-config.json".to_owned() => app_config_json,
248 }),
249 immutable: None,
250 metadata: console.managed_resource_meta(console.configmap_name()),
251 }
252 }
253
254 fn create_console_deployment_object(&self, console: &Console) -> Deployment {
255 let mut pod_template_labels = console.default_labels();
256 pod_template_labels.insert(
257 "materialize.cloud/name".to_owned(),
258 console.deployment_name(),
259 );
260 pod_template_labels.insert("app".to_owned(), "console".to_string());
261 pod_template_labels.insert("materialize.cloud/app".to_owned(), console.app_name());
262
263 let ports = vec![ContainerPort {
264 container_port: self.config.console_http_port.into(),
265 name: Some("http".into()),
266 protocol: Some("TCP".into()),
267 ..Default::default()
268 }];
269
270 let scheme = match console.spec.balancerd.scheme {
271 HttpConnectionScheme::Http => "http",
272 HttpConnectionScheme::Https => "https",
273 };
274 let mut env = vec![EnvVar {
275 name: "MZ_ENDPOINT".to_string(),
276 value: Some(format!(
277 "{}://{}.{}.svc.cluster.local:{}",
278 scheme,
279 console.spec.balancerd.service_name,
280 console.spec.balancerd.namespace,
281 self.config.balancerd_http_port,
282 )),
283 ..Default::default()
284 }];
285 let mut volumes = vec![Volume {
286 name: "app-config".to_string(),
287 config_map: Some(ConfigMapVolumeSource {
288 name: console.configmap_name(),
289 default_mode: Some(256),
290 optional: Some(false),
291 items: Some(vec![KeyToPath {
292 key: "app-config.json".to_string(),
293 path: "app-config.json".to_string(),
294 ..Default::default()
295 }]),
296 }),
297 ..Default::default()
298 }];
299 let mut volume_mounts = vec![VolumeMount {
300 name: "app-config".to_string(),
301 mount_path: "/usr/share/nginx/html/app-config".to_string(),
302 ..Default::default()
303 }];
304
305 let scheme = if issuer_ref_defined(
306 &self.config.default_certificate_specs.console_external,
307 &console.spec.external_certificate_spec,
308 ) {
309 volumes.push(Volume {
310 name: "external-certificate".to_owned(),
311 secret: Some(SecretVolumeSource {
312 default_mode: Some(0o400),
313 secret_name: Some(console.external_certificate_secret_name()),
314 items: None,
315 optional: Some(false),
316 }),
317 ..Default::default()
318 });
319 volume_mounts.push(VolumeMount {
320 name: "external-certificate".to_owned(),
321 mount_path: "/nginx/tls".to_owned(),
322 read_only: Some(true),
323 ..Default::default()
324 });
325 env.push(EnvVar {
326 name: "MZ_NGINX_LISTENER_CONFIG".to_string(),
327 value: Some(format!(
328 "listen {} ssl;
329ssl_certificate /nginx/tls/tls.crt;
330ssl_certificate_key /nginx/tls/tls.key;",
331 self.config.console_http_port
332 )),
333 ..Default::default()
334 });
335 Some("HTTPS".to_owned())
336 } else {
337 env.push(EnvVar {
338 name: "MZ_NGINX_LISTENER_CONFIG".to_string(),
339 value: Some(format!("listen {};", self.config.console_http_port)),
340 ..Default::default()
341 });
342 Some("HTTP".to_owned())
343 };
344
345 let probe = Probe {
346 http_get: Some(HTTPGetAction {
347 path: Some("/".to_string()),
348 port: IntOrString::Int(self.config.console_http_port.into()),
349 scheme,
350 ..Default::default()
351 }),
352 ..Default::default()
353 };
354
355 let security_context = if self.config.enable_security_context {
356 Some(SecurityContext {
360 run_as_non_root: Some(true),
361 capabilities: Some(Capabilities {
362 drop: Some(vec!["ALL".to_string()]),
363 ..Default::default()
364 }),
365 seccomp_profile: Some(SeccompProfile {
366 type_: "RuntimeDefault".to_string(),
367 ..Default::default()
368 }),
369 allow_privilege_escalation: Some(false),
370 ..Default::default()
371 })
372 } else {
373 None
374 };
375
376 let container = Container {
377 name: "console".to_owned(),
378 image: Some(console.spec.console_image_ref.clone()),
379 image_pull_policy: Some(self.config.image_pull_policy.to_string()),
380 ports: Some(ports),
381 env: Some(env),
382 startup_probe: Some(Probe {
383 period_seconds: Some(1),
384 failure_threshold: Some(10),
385 ..probe.clone()
386 }),
387 readiness_probe: Some(Probe {
388 period_seconds: Some(30),
389 failure_threshold: Some(1),
390 ..probe.clone()
391 }),
392 liveness_probe: Some(Probe {
393 period_seconds: Some(30),
394 ..probe.clone()
395 }),
396 resources: console
397 .spec
398 .resource_requirements
399 .clone()
400 .or_else(|| self.config.console_default_resources.clone()),
401 security_context,
402 volume_mounts: Some(volume_mounts),
403 ..Default::default()
404 };
405
406 let match_labels = pod_template_labels.clone();
407 pod_template_labels.extend(recommended_k8s_labels("console".into()));
408
409 let deployment_spec = DeploymentSpec {
410 replicas: Some(console.replicas()),
411 selector: LabelSelector {
412 match_labels: Some(match_labels),
413 ..Default::default()
414 },
415 template: PodTemplateSpec {
416 metadata: Some(ObjectMeta {
419 labels: Some(pod_template_labels),
420 ..Default::default()
421 }),
422 spec: Some(PodSpec {
423 containers: vec![container],
424 node_selector: Some(
425 self.config
426 .console_node_selector
427 .iter()
428 .map(|selector| (selector.key.clone(), selector.value.clone()))
429 .collect(),
430 ),
431 affinity: self.config.console_affinity.clone(),
432 tolerations: self.config.console_tolerations.clone(),
433 scheduler_name: self.config.scheduler_name.clone(),
434 volumes: Some(volumes),
435 security_context: Some(PodSecurityContext {
436 fs_group: Some(101),
437 ..Default::default()
438 }),
439 ..Default::default()
440 }),
441 },
442 ..Default::default()
443 };
444
445 Deployment {
446 metadata: ObjectMeta {
447 ..console.managed_resource_meta(console.deployment_name())
448 },
449 spec: Some(deployment_spec),
450 status: None,
451 }
452 }
453
454 fn create_console_service_object(&self, console: &Console) -> Service {
455 let selector =
456 btreemap! {"materialize.cloud/name".to_string() => console.deployment_name()};
457
458 let ports = vec![ServicePort {
459 name: Some("http".to_string()),
460 protocol: Some("TCP".to_string()),
461 port: self.config.console_http_port.into(),
462 target_port: Some(IntOrString::Int(self.config.console_http_port.into())),
463 ..Default::default()
464 }];
465
466 let spec = ServiceSpec {
467 type_: Some("ClusterIP".to_string()),
468 cluster_ip: Some("None".to_string()),
469 selector: Some(selector),
470 ports: Some(ports),
471 ..Default::default()
472 };
473
474 Service {
475 metadata: console.managed_resource_meta(console.service_name()),
476 spec: Some(spec),
477 status: None,
478 }
479 }
480
481 async fn fix_deployment(
484 &self,
485 deployment_api: &Api<Deployment>,
486 new_deployment: &Deployment,
487 ) -> Result<(), Error> {
488 let Some(mut existing_deployment) =
489 get_resource(deployment_api, &new_deployment.name_unchecked()).await?
490 else {
491 return Ok(());
492 };
493
494 if existing_deployment.spec.as_ref().unwrap().selector
495 == new_deployment.spec.as_ref().unwrap().selector
496 {
497 return Ok(());
498 }
499
500 warn!("found existing deployment with old label selector, fixing");
501
502 existing_deployment
505 .spec
506 .as_mut()
507 .unwrap()
508 .template
509 .metadata
510 .as_mut()
511 .unwrap()
512 .labels = new_deployment
513 .spec
514 .as_ref()
515 .unwrap()
516 .template
517 .metadata
518 .as_ref()
519 .unwrap()
520 .labels
521 .clone();
522
523 replace_resource(deployment_api, &existing_deployment).await?;
527 await_condition(
528 deployment_api.clone(),
529 &existing_deployment.name_unchecked(),
530 |deployment: Option<&Deployment>| {
531 let observed_generation = deployment
532 .and_then(|deployment| deployment.status.as_ref())
533 .and_then(|status| status.observed_generation)
534 .unwrap_or(0);
535 let current_generation = deployment
536 .and_then(|deployment| deployment.meta().generation)
537 .unwrap_or(0);
538 let previous_generation = existing_deployment.meta().generation.unwrap_or(0);
539 observed_generation == current_generation
540 && current_generation > previous_generation
541 },
542 )
543 .await
544 .map_err(|e| anyhow::anyhow!(e))?;
545 await_condition(
546 deployment_api.clone(),
547 &existing_deployment.name_unchecked(),
548 is_deployment_completed(),
549 )
550 .await
551 .map_err(|e| anyhow::anyhow!(e))?;
552
553 match kube::runtime::wait::delete::delete_and_finalize(
556 deployment_api.clone(),
557 &existing_deployment.name_unchecked(),
558 &DeleteParams::orphan(),
559 )
560 .await
561 {
562 Ok(_) => {}
563 Err(kube::runtime::wait::delete::Error::Delete(kube::Error::Api(e)))
564 if e.code == 404 =>
565 {
566 }
568 Err(e) => return Err(anyhow::anyhow!(e).into()),
569 }
570
571 Ok(())
577 }
578}
579
580#[async_trait::async_trait]
581impl k8s_controller::Context for Context {
582 type Resource = Console;
583 type Error = Error;
584
585 #[instrument(fields())]
586 async fn apply(
587 &self,
588 client: Client,
589 console: &Self::Resource,
590 _metadata: &mut TraceMetadata,
591 ) -> Result<Option<Action>, Self::Error> {
592 if console.status.is_none() {
593 let console_api: Api<Console> =
594 Api::namespaced(client.clone(), &console.meta().namespace.clone().unwrap());
595 let mut new_console = console.clone();
596 new_console.status = Some(console.status());
597 console_api
598 .replace_status(
599 &console.name_unchecked(),
600 &PostParams::default(),
601 &new_console,
602 )
603 .await?;
604 return Ok(None);
607 }
608
609 let namespace = console.namespace();
610 let network_policy_api: Api<NetworkPolicy> = Api::namespaced(client.clone(), &namespace);
611 let configmap_api: Api<ConfigMap> = Api::namespaced(client.clone(), &namespace);
612 let deployment_api: Api<Deployment> = Api::namespaced(client.clone(), &namespace);
613 let service_api: Api<Service> = Api::namespaced(client.clone(), &namespace);
614 let certificate_api: Api<Certificate> = Api::namespaced(client.clone(), &namespace);
615
616 trace!("creating new network policies");
617 let network_policies = self.create_network_policies(console);
618 for network_policy in &network_policies {
619 apply_resource(&network_policy_api, network_policy).await?;
620 }
621
622 trace!("creating new console configmap");
623 let console_configmap = self.create_console_app_configmap_object(console);
624 apply_resource(&configmap_api, &console_configmap).await?;
625
626 trace!("creating new console deployment");
627 let console_deployment = self.create_console_deployment_object(console);
628 self.fix_deployment(&deployment_api, &console_deployment)
629 .await?;
630 apply_resource(&deployment_api, &console_deployment).await?;
631
632 trace!("creating new console service");
633 let console_service = self.create_console_service_object(console);
634 apply_resource(&service_api, &console_service).await?;
635
636 let console_external_certificate = self.create_console_external_certificate(console)?;
637 if let Some(certificate) = &console_external_certificate {
638 trace!("creating new console external certificate");
639 apply_resource(&certificate_api, certificate).await?;
640 }
641
642 self.sync_deployment_status(&client, console).await?;
643
644 Ok(None)
645 }
646}