1use k8s_controller::{Outcome, 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 policy::v1::PodDisruptionBudget,
25 },
26 apimachinery::pkg::{
27 apis::meta::v1::{Condition, LabelSelector, Time},
28 util::intstr::IntOrString,
29 },
30 jiff::Timestamp,
31};
32use kube::{
33 Api, Client, Resource, ResourceExt,
34 api::{DeleteParams, ObjectMeta, PostParams},
35 runtime::{conditions::is_deployment_completed, controller::Action, wait::await_condition},
36};
37use maplit::btreemap;
38use serde::Serialize;
39use tracing::{trace, warn};
40
41use crate::{
42 Error,
43 k8s::{
44 apply_resource, get_resource, pod_disruption_budget, recommended_k8s_labels,
45 replace_resource,
46 },
47 tls::{DefaultCertificateSpecs, create_certificate, issuer_ref_defined},
48};
49use mz_cloud_resources::crd::{
50 ManagedResource,
51 console::v1alpha1::{Console, HttpConnectionScheme},
52 generated::cert_manager::certificates::{Certificate, CertificatePrivateKeyAlgorithm},
53};
54use mz_orchestrator_kubernetes::KubernetesImagePullPolicy;
55use mz_ore::{cli::KeyValueArg, instrument};
56use mz_server_core::listeners::AuthenticatorKind;
57
58pub const CONTROLLER_NAME: &str = "console";
61
62#[derive(Clone)]
63pub struct Config {
64 pub enable_security_context: bool,
65 pub enable_user_namespaces: bool,
67 pub enable_prometheus_scrape_annotations: bool,
68
69 pub image_pull_policy: KubernetesImagePullPolicy,
70 pub scheduler_name: Option<String>,
71 pub console_node_selector: Vec<KeyValueArg<String, String>>,
72 pub console_affinity: Option<Affinity>,
73 pub console_tolerations: Option<Vec<Toleration>>,
74 pub console_default_resources: Option<ResourceRequirements>,
75 pub network_policies_ingress_enabled: bool,
76 pub network_policies_ingress_cidrs: Vec<String>,
77
78 pub default_certificate_specs: DefaultCertificateSpecs,
79
80 pub console_http_port: u16,
81 pub console_internal_http_port: u16,
83 pub balancerd_http_port: u16,
84}
85
86#[derive(Serialize)]
87struct AppConfig {
88 version: String,
89 auth: AppConfigAuth,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 balancerd_dns_names: Option<Vec<String>>,
92}
93
94#[derive(Serialize)]
95struct AppConfigAuth {
96 mode: AuthenticatorKind,
97}
98
99pub struct Context {
100 config: Config,
101}
102
103impl Context {
104 pub fn new(config: Config) -> Self {
105 Self { config }
106 }
107
108 async fn sync_deployment_status(
109 &self,
110 client: &Client,
111 console: &Console,
112 ) -> Result<(), Error> {
113 let namespace = console.namespace();
114 let console_api: Api<Console> = Api::namespaced(client.clone(), &namespace);
115 let deployment_api: Api<Deployment> = Api::namespaced(client.clone(), &namespace);
116
117 let Some(deployment) = get_resource(&deployment_api, &console.deployment_name()).await?
118 else {
119 return Ok(());
120 };
121
122 let Some(deployment_conditions) = &deployment
123 .status
124 .as_ref()
125 .and_then(|status| status.conditions.as_ref())
126 else {
127 return Ok(());
130 };
131
132 let ready = deployment_conditions
133 .iter()
134 .any(|condition| condition.type_ == "Available" && condition.status == "True");
135 let ready_str = if ready { "True" } else { "False" };
136
137 let mut status = console.status.clone().unwrap();
138 if status
139 .conditions
140 .iter()
141 .any(|condition| condition.type_ == "Ready" && condition.status == ready_str)
142 {
143 return Ok(());
147 }
148
149 status.conditions = vec![Condition {
150 type_: "Ready".to_string(),
151 status: ready_str.to_string(),
152 last_transition_time: Time(Timestamp::now()),
153 message: format!(
154 "console deployment is{} ready",
155 if ready { "" } else { " not" }
156 ),
157 observed_generation: None,
158 reason: "DeploymentStatus".to_string(),
159 }];
160 let mut new_console = console.clone();
161 new_console.status = Some(status);
162
163 console_api
164 .replace_status(
165 &console.name_unchecked(),
166 &PostParams::default(),
167 &new_console,
168 )
169 .await?;
170
171 Ok(())
172 }
173
174 fn create_network_policies(&self, console: &Console) -> Vec<NetworkPolicy> {
175 let mut network_policies = Vec::new();
176 if self.config.network_policies_ingress_enabled {
177 let console_label_selector = LabelSelector {
178 match_labels: Some(
179 console
180 .default_labels()
181 .into_iter()
182 .chain([("materialize.cloud/app".to_owned(), console.app_name())])
183 .collect(),
184 ),
185 ..Default::default()
186 };
187 network_policies.extend([NetworkPolicy {
188 metadata: console.managed_resource_meta(console.name_prefixed("console-ingress")),
189 spec: Some(NetworkPolicySpec {
190 ingress: Some(vec![NetworkPolicyIngressRule {
191 from: Some(
192 self.config
193 .network_policies_ingress_cidrs
194 .iter()
195 .map(|cidr| NetworkPolicyPeer {
196 ip_block: Some(IPBlock {
197 cidr: cidr.to_owned(),
198 except: None,
199 }),
200 ..Default::default()
201 })
202 .collect(),
203 ),
204 ports: Some(vec![NetworkPolicyPort {
205 port: Some(IntOrString::Int(self.config.console_http_port.into())),
206 protocol: Some("TCP".to_string()),
207 ..Default::default()
208 }]),
209 ..Default::default()
210 }]),
211 pod_selector: Some(console_label_selector),
212 policy_types: Some(vec!["Ingress".to_owned()]),
213 ..Default::default()
214 }),
215 }]);
216 }
217 network_policies
218 }
219
220 fn create_console_external_certificate(
221 &self,
222 console: &Console,
223 ) -> anyhow::Result<Option<Certificate>> {
224 create_certificate(
225 self.config
226 .default_certificate_specs
227 .console_external
228 .clone(),
229 console,
230 console.spec.external_certificate_spec.clone(),
231 console.external_certificate_name(),
232 console.external_certificate_secret_name(),
233 None,
234 CertificatePrivateKeyAlgorithm::Ecdsa,
235 Some(256),
236 )
237 }
238
239 fn create_console_app_configmap_object(&self, console: &Console) -> ConfigMap {
240 let balancerd_dns_names = console.spec.balancerd.dns_names.clone();
241 let version: String = console
242 .spec
243 .console_image_ref
244 .rsplitn(2, ':')
245 .next()
246 .expect("at least one chunk, even if empty")
247 .to_owned();
248 let app_config_json = serde_json::to_string(&AppConfig {
249 version,
250 balancerd_dns_names,
251 auth: AppConfigAuth {
252 mode: console.spec.authenticator_kind,
253 },
254 })
255 .expect("known valid");
256 ConfigMap {
257 binary_data: None,
258 data: Some(btreemap! {
259 "app-config.json".to_owned() => app_config_json,
260 }),
261 immutable: None,
262 metadata: console.managed_resource_meta(console.configmap_name()),
263 }
264 }
265
266 fn create_console_deployment_object(&self, console: &Console) -> Deployment {
267 let mut pod_template_labels = console.default_labels();
268 pod_template_labels.insert(
269 "materialize.cloud/name".to_owned(),
270 console.deployment_name(),
271 );
272 pod_template_labels.insert("app".to_owned(), "console".to_string());
273 pod_template_labels.insert("materialize.cloud/app".to_owned(), console.app_name());
274
275 let ports = vec![
276 ContainerPort {
277 container_port: self.config.console_http_port.into(),
278 name: Some("http".into()),
279 protocol: Some("TCP".into()),
280 ..Default::default()
281 },
282 ContainerPort {
283 container_port: self.config.console_internal_http_port.into(),
284 name: Some("internal-http".into()),
285 protocol: Some("TCP".into()),
286 ..Default::default()
287 },
288 ];
289 let pod_template_annotations = self.config.enable_prometheus_scrape_annotations.then(|| {
290 btreemap! {
291 "prometheus.io/scrape".to_owned() => "true".to_string(),
292 "prometheus.io/port".to_owned() => self.config.console_internal_http_port.to_string(),
293 "prometheus.io/path".to_owned() => "/metrics".to_string(),
294 "prometheus.io/scheme".to_owned() => "http".to_string(),
295 }
296 });
297
298 let scheme = match console.spec.balancerd.scheme {
299 HttpConnectionScheme::Http => "http",
300 HttpConnectionScheme::Https => "https",
301 };
302 let mut env = vec![EnvVar {
303 name: "MZ_ENDPOINT".to_string(),
304 value: Some(format!(
305 "{}://{}.{}.svc.cluster.local:{}",
306 scheme,
307 console.spec.balancerd.service_name,
308 console.spec.balancerd.namespace,
309 self.config.balancerd_http_port,
310 )),
311 ..Default::default()
312 }];
313 env.push(EnvVar {
315 name: "MZ_NGINX_METRICS_LISTENER_CONFIG".to_string(),
316 value: Some(format!(
317 "listen {};",
318 self.config.console_internal_http_port
319 )),
320 ..Default::default()
321 });
322 let mut volumes = vec![Volume {
323 name: "app-config".to_string(),
324 config_map: Some(ConfigMapVolumeSource {
325 name: console.configmap_name(),
326 default_mode: Some(256),
327 optional: Some(false),
328 items: Some(vec![KeyToPath {
329 key: "app-config.json".to_string(),
330 path: "app-config.json".to_string(),
331 ..Default::default()
332 }]),
333 }),
334 ..Default::default()
335 }];
336 let mut volume_mounts = vec![VolumeMount {
337 name: "app-config".to_string(),
338 mount_path: "/usr/share/nginx/html/app-config".to_string(),
339 ..Default::default()
340 }];
341
342 let scheme = if issuer_ref_defined(
343 &self.config.default_certificate_specs.console_external,
344 &console.spec.external_certificate_spec,
345 ) {
346 volumes.push(Volume {
347 name: "external-certificate".to_owned(),
348 secret: Some(SecretVolumeSource {
349 default_mode: Some(0o400),
350 secret_name: Some(console.external_certificate_secret_name()),
351 items: None,
352 optional: Some(false),
353 }),
354 ..Default::default()
355 });
356 volume_mounts.push(VolumeMount {
357 name: "external-certificate".to_owned(),
358 mount_path: "/nginx/tls".to_owned(),
359 read_only: Some(true),
360 ..Default::default()
361 });
362 env.push(EnvVar {
363 name: "MZ_NGINX_LISTENER_CONFIG".to_string(),
364 value: Some(format!(
365 "listen {} ssl;
366ssl_certificate /nginx/tls/tls.crt;
367ssl_certificate_key /nginx/tls/tls.key;",
368 self.config.console_http_port
369 )),
370 ..Default::default()
371 });
372 Some("HTTPS".to_owned())
373 } else {
374 env.push(EnvVar {
375 name: "MZ_NGINX_LISTENER_CONFIG".to_string(),
376 value: Some(format!("listen {};", self.config.console_http_port)),
377 ..Default::default()
378 });
379 Some("HTTP".to_owned())
380 };
381
382 let probe = Probe {
383 http_get: Some(HTTPGetAction {
384 path: Some("/".to_string()),
385 port: IntOrString::Int(self.config.console_http_port.into()),
386 scheme,
387 ..Default::default()
388 }),
389 ..Default::default()
390 };
391
392 let security_context = if self.config.enable_security_context {
393 Some(SecurityContext {
397 run_as_non_root: Some(true),
398 capabilities: Some(Capabilities {
399 drop: Some(vec!["ALL".to_string()]),
400 ..Default::default()
401 }),
402 seccomp_profile: Some(SeccompProfile {
403 type_: "RuntimeDefault".to_string(),
404 ..Default::default()
405 }),
406 allow_privilege_escalation: Some(false),
407 ..Default::default()
408 })
409 } else {
410 None
411 };
412
413 let container = Container {
414 name: "console".to_owned(),
415 image: Some(console.spec.console_image_ref.clone()),
416 image_pull_policy: Some(self.config.image_pull_policy.to_string()),
417 ports: Some(ports),
418 env: Some(env),
419 startup_probe: Some(Probe {
420 period_seconds: Some(1),
421 failure_threshold: Some(10),
422 ..probe.clone()
423 }),
424 readiness_probe: Some(Probe {
425 period_seconds: Some(30),
426 failure_threshold: Some(1),
427 ..probe.clone()
428 }),
429 liveness_probe: Some(Probe {
430 period_seconds: Some(30),
431 ..probe.clone()
432 }),
433 resources: console
434 .spec
435 .resource_requirements
436 .clone()
437 .or_else(|| self.config.console_default_resources.clone()),
438 security_context,
439 volume_mounts: Some(volume_mounts),
440 ..Default::default()
441 };
442
443 let match_labels = pod_template_labels.clone();
444 pod_template_labels.extend(recommended_k8s_labels("console".into()));
445
446 let deployment_spec = DeploymentSpec {
447 replicas: Some(console.replicas()),
448 selector: LabelSelector {
449 match_labels: Some(match_labels),
450 ..Default::default()
451 },
452 template: PodTemplateSpec {
453 metadata: Some(ObjectMeta {
456 labels: Some(pod_template_labels),
457 annotations: pod_template_annotations,
458 ..Default::default()
459 }),
460 spec: Some(PodSpec {
461 containers: vec![container],
462 node_selector: Some(
463 self.config
464 .console_node_selector
465 .iter()
466 .map(|selector| (selector.key.clone(), selector.value.clone()))
467 .collect(),
468 ),
469 affinity: self.config.console_affinity.clone(),
470 tolerations: self.config.console_tolerations.clone(),
471 scheduler_name: self.config.scheduler_name.clone(),
472 volumes: Some(volumes),
473 security_context: Some(PodSecurityContext {
474 fs_group: Some(101),
475 ..Default::default()
476 }),
477 host_users: self.config.enable_user_namespaces.then_some(false),
480 ..Default::default()
481 }),
482 },
483 ..Default::default()
484 };
485
486 Deployment {
487 metadata: ObjectMeta {
488 ..console.managed_resource_meta(console.deployment_name())
489 },
490 spec: Some(deployment_spec),
491 status: None,
492 }
493 }
494
495 fn create_console_service_object(&self, console: &Console) -> Service {
496 let selector =
497 btreemap! {"materialize.cloud/name".to_string() => console.deployment_name()};
498
499 let ports = vec![ServicePort {
500 name: Some("http".to_string()),
501 protocol: Some("TCP".to_string()),
502 port: self.config.console_http_port.into(),
503 target_port: Some(IntOrString::Int(self.config.console_http_port.into())),
504 ..Default::default()
505 }];
506
507 let spec = ServiceSpec {
508 type_: Some("ClusterIP".to_string()),
509 cluster_ip: Some("None".to_string()),
510 selector: Some(selector),
511 ports: Some(ports),
512 ..Default::default()
513 };
514
515 Service {
516 metadata: console.managed_resource_meta(console.service_name()),
517 spec: Some(spec),
518 status: None,
519 }
520 }
521
522 async fn fix_deployment(
525 &self,
526 deployment_api: &Api<Deployment>,
527 new_deployment: &Deployment,
528 ) -> Result<(), Error> {
529 let Some(mut existing_deployment) =
530 get_resource(deployment_api, &new_deployment.name_unchecked()).await?
531 else {
532 return Ok(());
533 };
534
535 if existing_deployment.spec.as_ref().unwrap().selector
536 == new_deployment.spec.as_ref().unwrap().selector
537 {
538 return Ok(());
539 }
540
541 warn!("found existing deployment with old label selector, fixing");
542
543 existing_deployment
546 .spec
547 .as_mut()
548 .unwrap()
549 .template
550 .metadata
551 .as_mut()
552 .unwrap()
553 .labels = new_deployment
554 .spec
555 .as_ref()
556 .unwrap()
557 .template
558 .metadata
559 .as_ref()
560 .unwrap()
561 .labels
562 .clone();
563
564 replace_resource(deployment_api, &existing_deployment).await?;
568 await_condition(
569 deployment_api.clone(),
570 &existing_deployment.name_unchecked(),
571 |deployment: Option<&Deployment>| {
572 let observed_generation = deployment
573 .and_then(|deployment| deployment.status.as_ref())
574 .and_then(|status| status.observed_generation)
575 .unwrap_or(0);
576 let current_generation = deployment
577 .and_then(|deployment| deployment.meta().generation)
578 .unwrap_or(0);
579 let previous_generation = existing_deployment.meta().generation.unwrap_or(0);
580 observed_generation == current_generation
581 && current_generation > previous_generation
582 },
583 )
584 .await
585 .map_err(|e| anyhow::anyhow!(e))?;
586 await_condition(
587 deployment_api.clone(),
588 &existing_deployment.name_unchecked(),
589 is_deployment_completed(),
590 )
591 .await
592 .map_err(|e| anyhow::anyhow!(e))?;
593
594 match kube::runtime::wait::delete::delete_and_finalize(
597 deployment_api.clone(),
598 &existing_deployment.name_unchecked(),
599 &DeleteParams::orphan(),
600 )
601 .await
602 {
603 Ok(_) => {}
604 Err(kube::runtime::wait::delete::Error::Delete(kube::Error::Api(e)))
605 if e.code == 404 =>
606 {
607 }
609 Err(e) => return Err(anyhow::anyhow!(e).into()),
610 }
611
612 Ok(())
618 }
619}
620
621#[async_trait::async_trait]
622impl k8s_controller::Context for Context {
623 type Resource = Console;
624 type Error = Error;
625
626 #[instrument(fields())]
627 async fn apply(
628 &self,
629 client: Client,
630 console: &Self::Resource,
631 metadata: &mut TraceMetadata,
632 ) -> Result<Option<Action>, Self::Error> {
633 if console.status.is_none() {
634 let step = metadata.step("initialize_status");
635 let console_api: Api<Console> =
636 Api::namespaced(client.clone(), &console.meta().namespace.clone().unwrap());
637 let mut new_console = console.clone();
638 new_console.status = Some(console.status());
639 console_api
640 .replace_status(
641 &console.name_unchecked(),
642 &PostParams::default(),
643 &new_console,
644 )
645 .await?;
646 step.finish(Outcome::Completed);
647 return Ok(None);
650 }
651
652 let namespace = console.namespace();
653 let network_policy_api: Api<NetworkPolicy> = Api::namespaced(client.clone(), &namespace);
654 let configmap_api: Api<ConfigMap> = Api::namespaced(client.clone(), &namespace);
655 let deployment_api: Api<Deployment> = Api::namespaced(client.clone(), &namespace);
656 let service_api: Api<Service> = Api::namespaced(client.clone(), &namespace);
657 let certificate_api: Api<Certificate> = Api::namespaced(client.clone(), &namespace);
658 let pdb_api: Api<PodDisruptionBudget> = Api::namespaced(client.clone(), &namespace);
659
660 let step = metadata.step("network_policies");
661 trace!("creating new network policies");
662 let network_policies = self.create_network_policies(console);
663 for network_policy in &network_policies {
664 apply_resource(&network_policy_api, network_policy).await?;
665 }
666 step.finish(if network_policies.is_empty() {
667 Outcome::Skipped
668 } else {
669 Outcome::Completed
670 });
671
672 let step = metadata.step("configmap");
673 trace!("creating new console configmap");
674 let console_configmap = self.create_console_app_configmap_object(console);
675 apply_resource(&configmap_api, &console_configmap).await?;
676 step.finish(Outcome::Completed);
677
678 let step = metadata.step("deployment");
679 trace!("creating new console deployment");
680 let console_deployment = self.create_console_deployment_object(console);
681 self.fix_deployment(&deployment_api, &console_deployment)
682 .await?;
683 apply_resource(&deployment_api, &console_deployment).await?;
684 step.finish(Outcome::Completed);
685
686 let step = metadata.step("pod_disruption_budget");
687 let pdb = pod_disruption_budget(
688 console.managed_resource_meta(console.deployment_name()),
689 &console_deployment,
690 );
691 apply_resource(&pdb_api, &pdb).await?;
692 step.finish(Outcome::Completed);
693
694 let step = metadata.step("service");
695 trace!("creating new console service");
696 let console_service = self.create_console_service_object(console);
697 apply_resource(&service_api, &console_service).await?;
698 step.finish(Outcome::Completed);
699
700 let step = metadata.step("certificate");
701 let console_external_certificate = self.create_console_external_certificate(console)?;
702 if let Some(certificate) = &console_external_certificate {
703 trace!("creating new console external certificate");
704 apply_resource(&certificate_api, certificate).await?;
705 step.finish(Outcome::Completed);
706 } else {
707 step.finish(Outcome::Skipped);
708 }
709
710 let step = metadata.step("sync_status");
711 self.sync_deployment_status(&client, console).await?;
712 step.finish(Outcome::Completed);
713
714 Ok(None)
715 }
716}
717
718#[cfg(test)]
719mod tests {
720 use mz_cloud_resources::crd::console::v1alpha1::ConsoleSpec;
721
722 use super::*;
723
724 fn test_context(enable_prometheus_scrape_annotations: bool) -> Context {
725 Context::new(Config {
726 enable_security_context: false,
727 enable_user_namespaces: false,
728 enable_prometheus_scrape_annotations,
729 image_pull_policy: KubernetesImagePullPolicy::IfNotPresent,
730 scheduler_name: None,
731 console_node_selector: vec![],
732 console_affinity: None,
733 console_tolerations: None,
734 console_default_resources: None,
735 network_policies_ingress_enabled: false,
736 network_policies_ingress_cidrs: vec![],
737 default_certificate_specs: DefaultCertificateSpecs::default(),
738 console_http_port: 8080,
739 console_internal_http_port: 8081,
740 balancerd_http_port: 6876,
741 })
742 }
743
744 fn test_console() -> Console {
745 let mut console = Console::new(
746 "test",
747 ConsoleSpec {
748 console_image_ref: "materialize/console:v0.0.0".to_owned(),
749 ..Default::default()
750 },
751 );
752 console.metadata.namespace = Some("test".to_owned());
753 console.metadata.uid = Some("test".to_owned());
754 console.status = Some(console.status());
755 console
756 }
757
758 #[mz_ore::test]
759 fn user_namespaces_set_host_users() {
760 for (enabled, expected) in [(false, None), (true, Some(false))] {
761 let mut context = test_context(false);
762 context.config.enable_user_namespaces = enabled;
763 let pod = context
764 .create_console_deployment_object(&test_console())
765 .spec
766 .unwrap()
767 .template
768 .spec
769 .unwrap();
770 assert_eq!(pod.host_users, expected);
771 }
772 }
773
774 #[mz_ore::test]
775 fn metrics_listener_is_exposed_and_annotated_when_enabled() {
776 for annotate in [false, true] {
777 let deployment =
778 test_context(annotate).create_console_deployment_object(&test_console());
779 let template = deployment.spec.unwrap().template;
780 let container = &template.spec.unwrap().containers[0];
781
782 let port = container
783 .ports
784 .iter()
785 .flatten()
786 .find(|port| port.name.as_deref() == Some("internal-http"))
787 .expect("the metrics port is declared");
788 assert_eq!(port.container_port, 8081);
789 let listener = container
790 .env
791 .iter()
792 .flatten()
793 .find(|var| var.name == "MZ_NGINX_METRICS_LISTENER_CONFIG")
794 .expect("nginx is told where to listen");
795 assert_eq!(listener.value.as_deref(), Some("listen 8081;"));
796
797 let scrape_port = template
798 .metadata
799 .and_then(|meta| meta.annotations)
800 .and_then(|annotations| annotations.get("prometheus.io/port").cloned());
801 assert_eq!(scrape_port.as_deref(), annotate.then_some("8081"));
802 }
803 }
804}