1use std::collections::{BTreeMap, BTreeSet};
49use std::sync::{Arc, Mutex};
50use std::time::{Duration, Instant};
51
52use anyhow::{Context as _, bail};
53use futures::future;
54use k8s_openapi::api::core::v1::{Node, Pod};
55use kube::{
56 Api, Client,
57 api::{ListParams, Patch, PatchParams},
58};
59use serde::Deserialize;
60use serde_json::json;
61use tracing::{debug, info, warn};
62use uuid::Uuid;
63
64use crate::k8s::get_resource;
65use mz_cloud_resources::crd::materialize::{FORCE_ROLLOUT_ANNOTATION, v1::Materialize};
66
67const GKE_NODE_POOL_LABEL: &str = "cloud.google.com/gke-nodepool";
69
70const DATA_PLANE_POD_SELECTOR: &str = "app.kubernetes.io/name in (environmentd,clusterd),materialize.cloud/organization-name,materialize.cloud/organization-namespace";
75
76const MAX_ARMED_DURATION: Duration = Duration::from_secs(14 * 24 * 60 * 60);
81
82#[derive(Debug, Clone)]
83pub struct Config {
84 pub notification_subscription: String,
87 pub cluster_name: String,
89 pub cluster_location: String,
91 pub watched_node_pools: Vec<String>,
93 pub scan_interval: Duration,
95 pub gke_poll_interval: Duration,
98 pub trigger_cooldown: Duration,
105}
106
107impl Config {
108 pub fn new(
109 notification_subscription: String,
110 cluster_name: String,
111 cluster_location: String,
112 watched_node_pools: Vec<String>,
113 ) -> Result<Self, anyhow::Error> {
114 let parts: Vec<_> = notification_subscription.split('/').collect();
115 if !matches!(&*parts, ["projects", p, "subscriptions", s] if !p.is_empty() && !s.is_empty())
116 {
117 bail!(
118 "invalid Pub/Sub subscription {notification_subscription:?}: expected projects/{{project}}/subscriptions/{{subscription}}"
119 );
120 }
121 Ok(Self {
122 notification_subscription,
123 cluster_name,
124 cluster_location,
125 watched_node_pools,
126 scan_interval: Duration::from_secs(60),
127 gke_poll_interval: Duration::from_secs(60 * 60),
128 trigger_cooldown: Duration::from_secs(300),
129 })
130 }
131
132 fn project(&self) -> &str {
133 self.notification_subscription
134 .split('/')
135 .nth(1)
136 .expect("validated in Config::new")
137 }
138
139 fn watches_pool(&self, pool: &str) -> bool {
140 self.watched_node_pools.is_empty() || self.watched_node_pools.iter().any(|p| p == pool)
141 }
142
143 fn node_pool_url(&self, pool: &str) -> String {
144 format!(
145 "https://container.googleapis.com/v1beta1/projects/{}/locations/{}/clusters/{}/nodePools/{}",
146 self.project(),
147 self.cluster_location,
148 self.cluster_name,
149 pool,
150 )
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
160#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
161enum BlueGreenPhase {
162 PhaseUnspecified,
163 UpdateStarted,
164 CreatingGreenPool,
165 CordoningBluePool,
166 WaitingToDrainBluePool,
167 DrainingBluePool,
168 NodePoolSoaking,
169 DeletingBluePool,
170 RollbackStarted,
171 #[serde(other)]
173 Unknown,
174}
175
176impl BlueGreenPhase {
177 fn blue_pool_fully_cordoned(&self) -> bool {
185 matches!(
186 self,
187 Self::WaitingToDrainBluePool
188 | Self::DrainingBluePool
189 | Self::NodePoolSoaking
190 | Self::DeletingBluePool
191 )
192 }
193}
194
195#[derive(Debug, Default)]
196struct ArmedPools {
197 pools: BTreeMap<String, ArmedPool>,
198}
199
200#[derive(Debug)]
201struct ArmedPool {
202 armed_at: Instant,
203}
204
205impl ArmedPools {
206 fn arm(&mut self, pool: &str, reason: &str) {
207 self.pools.entry(pool.to_owned()).or_insert_with(|| {
208 info!(pool, reason, "arming node pool");
209 ArmedPool {
210 armed_at: Instant::now(),
211 }
212 });
213 }
214}
215
216pub async fn run(client: Client, config: Config) {
223 info!(
224 subscription = config.notification_subscription,
225 cluster_name = config.cluster_name,
226 cluster_location = config.cluster_location,
227 watched_node_pools = ?config.watched_node_pools,
228 "starting GCP node upgrade watcher",
229 );
230
231 let armed = Arc::new(Mutex::new(ArmedPools::default()));
232
233 let gcp = Arc::new(GcpApiClient::new().await);
234
235 future::join(
242 mz_ore::task::spawn(
243 || "gcp node upgrade notification subscriber",
244 subscriber_loop(Arc::clone(&gcp), config.clone(), Arc::clone(&armed)),
245 )
246 .abort_on_drop(),
247 mz_ore::task::spawn(
248 || "gcp node upgrade scan",
249 scan_loop(client, gcp, config, armed),
250 )
251 .abort_on_drop(),
252 )
253 .await;
254}
255
256async fn scan_loop(
260 client: Client,
261 gcp: Arc<GcpApiClient>,
262 config: Config,
263 armed: Arc<Mutex<ArmedPools>>,
264) {
265 let mut scan_interval = tokio::time::interval(config.scan_interval);
266 scan_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
267 let mut last_gke_poll: Option<Instant> = None;
268 let mut last_triggered = BTreeMap::new();
269 loop {
270 scan_interval.tick().await;
271
272 if last_gke_poll.is_none_or(|at| at.elapsed() >= config.gke_poll_interval) {
276 match poll_gke_for_upgrades(&gcp, &config).await {
277 Ok(upgrading_pools) => {
278 last_gke_poll = Some(Instant::now());
279 let mut armed = armed.lock().expect("poisoned");
280 for pool in upgrading_pools {
281 armed.arm(&pool, "GKE API reports an upgrade in progress");
282 }
283 }
284 Err(e) => {
285 warn!("failed to poll GKE for node pool upgrades: {e:#}");
286 }
287 }
288 }
289
290 let pools: Vec<String> = {
291 let armed = armed.lock().expect("poisoned");
292 armed.pools.keys().cloned().collect()
293 };
294 for pool in pools {
295 match check_armed_pool(&client, &gcp, &config, &pool, &mut last_triggered).await {
296 Ok(PoolCheckOutcome::StillUpgrading) => {}
297 Ok(PoolCheckOutcome::Done(reason)) => {
298 info!(pool, reason, "disarming node pool");
299 armed.lock().expect("poisoned").pools.remove(&pool);
300 }
301 Err(e) => {
302 warn!(pool, "failed to check armed node pool: {e:#}");
303 let armed_at = armed
304 .lock()
305 .expect("poisoned")
306 .pools
307 .get(&pool)
308 .map(|state| state.armed_at);
309 if armed_at.is_some_and(|at| at.elapsed() > MAX_ARMED_DURATION) {
310 warn!(
311 pool,
312 "disarming node pool: armed for longer than {MAX_ARMED_DURATION:?}"
313 );
314 armed.lock().expect("poisoned").pools.remove(&pool);
315 }
316 }
317 }
318 }
319 }
320}
321
322enum PoolCheckOutcome {
323 StillUpgrading,
324 Done(&'static str),
325}
326
327async fn check_armed_pool(
330 client: &Client,
331 gcp: &GcpApiClient,
332 config: &Config,
333 pool: &str,
334 last_triggered: &mut BTreeMap<(String, String), Instant>,
335) -> Result<PoolCheckOutcome, anyhow::Error> {
336 #[derive(Deserialize)]
337 #[serde(rename_all = "camelCase")]
338 struct NodePool {
339 update_info: Option<UpdateInfo>,
340 }
341 #[derive(Deserialize)]
342 #[serde(rename_all = "camelCase")]
343 struct UpdateInfo {
344 blue_green_info: Option<BlueGreenInfo>,
345 }
346 #[derive(Deserialize)]
347 #[serde(rename_all = "camelCase")]
348 struct BlueGreenInfo {
349 phase: Option<BlueGreenPhase>,
350 }
351
352 let node_pool: NodePool = serde_json::from_value(gcp.get(&config.node_pool_url(pool)).await?)
353 .context("parsing GKE nodePool response")?;
354
355 let Some(phase) = node_pool
356 .update_info
357 .and_then(|info| info.blue_green_info)
358 .and_then(|info| info.phase)
359 else {
360 return Ok(PoolCheckOutcome::Done(
366 "no blue-green upgrade in progress; if this pool was upgraded with the \
367 surge strategy, its pods were NOT protected: configure the pool to use \
368 blue-green upgrades",
369 ));
370 };
371
372 debug!(pool, ?phase, "checked armed node pool");
373 if phase == BlueGreenPhase::RollbackStarted {
374 return Ok(PoolCheckOutcome::StillUpgrading);
379 }
380 if !phase.blue_pool_fully_cordoned() {
381 return Ok(PoolCheckOutcome::StillUpgrading);
386 }
387
388 let node_api: Api<Node> = Api::all(client.clone());
391 let nodes = node_api
392 .list(&ListParams::default().labels(&format!("{GKE_NODE_POOL_LABEL}={pool}")))
393 .await?;
394 let cordoned_nodes: BTreeSet<String> = nodes
395 .items
396 .into_iter()
397 .filter(|node| {
398 node.spec
399 .as_ref()
400 .and_then(|spec| spec.unschedulable)
401 .unwrap_or(false)
402 })
403 .filter_map(|node| node.metadata.name)
404 .collect();
405 if cordoned_nodes.is_empty() {
406 return Ok(PoolCheckOutcome::StillUpgrading);
407 }
408 debug!(
409 pool,
410 ?cordoned_nodes,
411 "found cordoned nodes in armed node pool"
412 );
413
414 let pod_api: Api<Pod> = Api::all(client.clone());
418 let pods = pod_api
419 .list(&ListParams::default().labels(DATA_PLANE_POD_SELECTOR))
420 .await?;
421 let mut affected_instances: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
422 for pod in pods.items {
423 if pod.metadata.deletion_timestamp.is_some() {
424 continue;
425 }
426 let Some(node_name) = pod.spec.as_ref().and_then(|spec| spec.node_name.clone()) else {
427 continue;
428 };
429 if !cordoned_nodes.contains(&node_name) {
430 continue;
431 }
432 let Some(labels) = &pod.metadata.labels else {
433 continue;
434 };
435 let (Some(namespace), Some(name)) = (
436 labels.get("materialize.cloud/organization-namespace"),
437 labels.get("materialize.cloud/organization-name"),
438 ) else {
439 continue;
440 };
441 affected_instances
442 .entry((namespace.clone(), name.clone()))
443 .or_default()
444 .insert(node_name);
445 }
446
447 for ((namespace, name), nodes) in affected_instances {
448 if let Err(e) =
449 maybe_trigger_rollout(client, config, last_triggered, &namespace, &name, &nodes).await
450 {
451 warn!(
452 namespace,
453 name, "failed to trigger rollout for instance on cordoned nodes: {e:#}"
454 );
455 }
456 }
457
458 Ok(PoolCheckOutcome::StillUpgrading)
459}
460
461async fn maybe_trigger_rollout(
464 client: &Client,
465 config: &Config,
466 last_triggered: &mut BTreeMap<(String, String), Instant>,
467 namespace: &str,
468 name: &str,
469 nodes: &BTreeSet<String>,
470) -> Result<(), anyhow::Error> {
471 let key = (namespace.to_owned(), name.to_owned());
472 if let Some(triggered_at) = last_triggered.get(&key) {
473 if triggered_at.elapsed() < config.trigger_cooldown {
474 debug!(
475 namespace,
476 name, "skipping rollout trigger: instance is in the trigger cooldown period"
477 );
478 return Ok(());
479 }
480 }
481
482 let mz_api: Api<Materialize> = Api::namespaced(client.clone(), namespace);
483 let Some(mz) = get_resource(&mz_api, name).await? else {
484 warn!(
485 namespace,
486 name, "pods on cordoned nodes belong to a Materialize instance which no longer exists"
487 );
488 return Ok(());
489 };
490 if mz.rollout_requested() {
491 debug!(
492 namespace,
493 name, "skipping rollout trigger: a rollout is already in progress"
494 );
495 return Ok(());
496 }
497
498 let force_rollout = Uuid::new_v4();
499 info!(
500 namespace,
501 name,
502 %force_rollout,
503 ?nodes,
504 "triggering rollout: instance has pods on nodes which are being upgraded away",
505 );
506 mz_api
513 .patch(
514 name,
515 &PatchParams::default(),
516 &Patch::Merge(json!({
517 "metadata": {
518 "annotations": {
519 FORCE_ROLLOUT_ANNOTATION: force_rollout,
520 }
521 }
522 })),
523 )
524 .await?;
525 last_triggered.insert(key, Instant::now());
526 Ok(())
527}
528
529struct GcpApiClient {
533 http: reqwest::Client,
534 auth: Arc<dyn gcp_auth::TokenProvider>,
535}
536
537impl GcpApiClient {
538 async fn new() -> Self {
539 let auth = loop {
540 match gcp_auth::provider().await {
541 Ok(auth) => break auth,
542 Err(e) => {
543 warn!("failed to initialize GCP credentials, retrying: {e:#}");
544 tokio::time::sleep(Duration::from_secs(10)).await;
545 }
546 }
547 };
548 let http = reqwest::Client::builder()
549 .timeout(Duration::from_secs(120))
550 .build()
551 .expect("valid client config");
552 Self { http, auth }
553 }
554
555 async fn post(
556 &self,
557 url: &str,
558 body: serde_json::Value,
559 ) -> Result<serde_json::Value, anyhow::Error> {
560 let token = self
561 .auth
562 .token(&["https://www.googleapis.com/auth/cloud-platform"])
563 .await
564 .context("fetching GCP auth token")?;
565 let response = self
566 .http
567 .post(url)
568 .bearer_auth(token.as_str())
569 .json(&body)
570 .send()
571 .await?;
572 let status = response.status();
573 if !status.is_success() {
574 let body = response.text().await.unwrap_or_default();
575 bail!("{url} returned {status}: {body}");
576 }
577 Ok(response.json().await?)
578 }
579
580 async fn get(&self, url: &str) -> Result<serde_json::Value, anyhow::Error> {
581 let token = self
582 .auth
583 .token(&["https://www.googleapis.com/auth/cloud-platform"])
584 .await
585 .context("fetching GCP auth token")?;
586 let response = self
587 .http
588 .get(url)
589 .bearer_auth(token.as_str())
590 .send()
591 .await?;
592 let status = response.status();
593 if !status.is_success() {
594 let body = response.text().await.unwrap_or_default();
595 bail!("{url} returned {status}: {body}");
596 }
597 Ok(response.json().await?)
598 }
599}
600
601async fn poll_gke_for_upgrades(
604 gcp: &GcpApiClient,
605 config: &Config,
606) -> Result<Vec<String>, anyhow::Error> {
607 #[derive(Deserialize)]
608 #[serde(rename_all = "camelCase")]
609 struct NodePoolsResponse {
610 #[serde(default)]
611 node_pools: Vec<NodePool>,
612 }
613 #[derive(Deserialize)]
614 #[serde(rename_all = "camelCase")]
615 struct NodePool {
616 name: String,
617 #[serde(default)]
618 status: String,
619 update_info: Option<UpdateInfo>,
620 }
621 #[derive(Deserialize)]
622 #[serde(rename_all = "camelCase")]
623 struct UpdateInfo {
624 blue_green_info: Option<serde_json::Value>,
625 }
626
627 let url = format!(
628 "https://container.googleapis.com/v1beta1/projects/{}/locations/{}/clusters/{}/nodePools",
629 config.project(),
630 config.cluster_location,
631 config.cluster_name,
632 );
633 let response: NodePoolsResponse =
634 serde_json::from_value(gcp.get(&url).await?).context("parsing GKE nodePools response")?;
635
636 Ok(response
637 .node_pools
638 .into_iter()
639 .filter(|pool| config.watches_pool(&pool.name))
640 .filter(|pool| {
641 pool.status == "RECONCILING"
646 || pool
647 .update_info
648 .as_ref()
649 .is_some_and(|info| info.blue_green_info.is_some())
650 })
651 .map(|pool| pool.name)
652 .collect())
653}
654
655async fn subscriber_loop(gcp: Arc<GcpApiClient>, config: Config, armed: Arc<Mutex<ArmedPools>>) {
658 let base_url = format!(
659 "https://pubsub.googleapis.com/v1/{}",
660 config.notification_subscription
661 );
662 loop {
663 let response = match gcp
664 .post(&format!("{base_url}:pull"), json!({"maxMessages": 100}))
665 .await
666 {
667 Ok(response) => response,
668 Err(e) => {
669 warn!("failed to pull GKE cluster notifications: {e:#}");
670 tokio::time::sleep(Duration::from_secs(30)).await;
671 continue;
672 }
673 };
674
675 #[derive(Deserialize)]
676 #[serde(rename_all = "camelCase")]
677 struct PullResponse {
678 #[serde(default)]
679 received_messages: Vec<ReceivedMessage>,
680 }
681 #[derive(Deserialize)]
682 #[serde(rename_all = "camelCase")]
683 struct ReceivedMessage {
684 ack_id: String,
685 message: Option<PubsubMessage>,
686 }
687 #[derive(Deserialize)]
688 #[serde(rename_all = "camelCase")]
689 struct PubsubMessage {
690 #[serde(default)]
691 attributes: BTreeMap<String, String>,
692 }
693
694 let response: PullResponse = match serde_json::from_value(response) {
695 Ok(response) => response,
696 Err(e) => {
697 warn!("failed to parse Pub/Sub pull response: {e:#}");
698 tokio::time::sleep(Duration::from_secs(30)).await;
699 continue;
700 }
701 };
702 if response.received_messages.is_empty() {
703 continue;
706 }
707
708 let mut ack_ids = Vec::new();
709 for received in response.received_messages {
710 ack_ids.push(received.ack_id);
711 let Some(message) = received.message else {
712 continue;
713 };
714 if let Some(pool) = upgrading_node_pool(&config, &message.attributes) {
715 let mut armed = armed.lock().expect("poisoned");
716 armed.arm(&pool, "received a GKE node pool UpgradeEvent notification");
717 }
718 }
719
720 if let Err(e) = gcp
721 .post(
722 &format!("{base_url}:acknowledge"),
723 json!({"ackIds": ack_ids}),
724 )
725 .await
726 {
727 warn!("failed to acknowledge GKE cluster notifications: {e:#}");
729 }
730 }
731}
732
733fn upgrading_node_pool(config: &Config, attributes: &BTreeMap<String, String>) -> Option<String> {
736 #[derive(Deserialize)]
737 #[serde(rename_all = "camelCase")]
738 struct UpgradeEvent {
739 #[serde(default)]
740 resource_type: String,
741 #[serde(default)]
742 resource: String,
743 }
744
745 if attributes.get("type_url").map(String::as_str)
746 != Some("type.googleapis.com/google.container.v1beta1.UpgradeEvent")
747 {
748 return None;
749 }
750 if attributes.get("cluster_name") != Some(&config.cluster_name)
754 || attributes.get("cluster_location") != Some(&config.cluster_location)
755 {
756 return None;
757 }
758 let event: UpgradeEvent = match serde_json::from_str(attributes.get("payload")?) {
759 Ok(event) => event,
760 Err(e) => {
761 warn!("failed to parse UpgradeEvent payload: {e:#}");
762 return None;
763 }
764 };
765 if event.resource_type != "NODE_POOL" {
766 return None;
767 }
768 let pool = match event.resource.split('/').collect::<Vec<_>>()[..] {
771 [_, _, _, _, _, _, "nodePools", pool] => pool.to_owned(),
772 _ => {
773 warn!(
774 resource = event.resource,
775 "unexpected resource format in UpgradeEvent"
776 );
777 return None;
778 }
779 };
780 if !config.watches_pool(&pool) {
781 debug!(pool, "ignoring UpgradeEvent for unwatched node pool");
782 return None;
783 }
784 Some(pool)
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790
791 fn test_config() -> Config {
792 Config::new(
793 "projects/my-project/subscriptions/my-sub".into(),
794 "my-cluster".into(),
795 "us-central1".into(),
796 vec!["materialize".into()],
797 )
798 .unwrap()
799 }
800
801 fn upgrade_event_attributes(
802 cluster_name: &str,
803 cluster_location: &str,
804 resource_type: &str,
805 resource: &str,
806 ) -> BTreeMap<String, String> {
807 BTreeMap::from_iter([
808 (
809 "type_url".to_owned(),
810 "type.googleapis.com/google.container.v1beta1.UpgradeEvent".to_owned(),
811 ),
812 ("cluster_name".to_owned(), cluster_name.to_owned()),
813 ("cluster_location".to_owned(), cluster_location.to_owned()),
814 ("project_id".to_owned(), "1234567890".to_owned()),
815 (
816 "payload".to_owned(),
817 serde_json::to_string(&json!({
818 "resourceType": resource_type,
819 "operation": "operation-1234",
820 "operationStartTime": "2026-07-22T00:00:00Z",
821 "currentVersion": "1.32.1-gke.1",
822 "targetVersion": "1.33.1-gke.1",
823 "resource": resource,
824 }))
825 .unwrap(),
826 ),
827 ])
828 }
829
830 #[mz_ore::test]
831 fn test_config_validation() {
832 assert!(
833 Config::new("my-sub".into(), "c".into(), "l".into(), vec![]).is_err(),
834 "bare subscription names are rejected"
835 );
836 assert!(
837 Config::new(
838 "projects//subscriptions/my-sub".into(),
839 "c".into(),
840 "l".into(),
841 vec![]
842 )
843 .is_err(),
844 "empty project is rejected"
845 );
846 let config = test_config();
847 assert_eq!(config.project(), "my-project");
848 assert_eq!(
849 config.node_pool_url("materialize"),
850 "https://container.googleapis.com/v1beta1/projects/my-project/locations/us-central1/clusters/my-cluster/nodePools/materialize",
851 );
852 }
853
854 #[mz_ore::test]
855 fn test_blue_green_phase_parsing() {
856 for (json, expected) in [
857 ("\"DRAINING_BLUE_POOL\"", BlueGreenPhase::DrainingBluePool),
858 ("\"NODE_POOL_SOAKING\"", BlueGreenPhase::NodePoolSoaking),
859 ("\"CORDONING_BLUE_POOL\"", BlueGreenPhase::CordoningBluePool),
860 (
861 "\"WAITING_TO_DRAIN_BLUE_POOL\"",
862 BlueGreenPhase::WaitingToDrainBluePool,
863 ),
864 ("\"SOME_FUTURE_PHASE\"", BlueGreenPhase::Unknown),
866 ] {
867 let phase: BlueGreenPhase = serde_json::from_str(json).unwrap();
868 assert_eq!(phase, expected, "json: {json}");
869 }
870 }
871
872 #[mz_ore::test]
873 fn test_blue_pool_fully_cordoned() {
874 for (phase, expected) in [
875 (BlueGreenPhase::UpdateStarted, false),
876 (BlueGreenPhase::CreatingGreenPool, false),
877 (BlueGreenPhase::CordoningBluePool, false),
880 (BlueGreenPhase::WaitingToDrainBluePool, true),
881 (BlueGreenPhase::DrainingBluePool, true),
882 (BlueGreenPhase::NodePoolSoaking, true),
883 (BlueGreenPhase::DeletingBluePool, true),
884 (BlueGreenPhase::RollbackStarted, false),
885 (BlueGreenPhase::Unknown, false),
886 ] {
887 assert_eq!(
888 phase.blue_pool_fully_cordoned(),
889 expected,
890 "phase: {phase:?}"
891 );
892 }
893 }
894
895 #[mz_ore::test]
896 fn test_upgrading_node_pool() {
897 let config = test_config();
898 let pool_resource =
899 "projects/my-project/locations/us-central1/clusters/my-cluster/nodePools/materialize";
900
901 assert_eq!(
903 upgrading_node_pool(
904 &config,
905 &upgrade_event_attributes("my-cluster", "us-central1", "NODE_POOL", pool_resource),
906 ),
907 Some("materialize".to_owned()),
908 );
909
910 assert_eq!(
913 upgrading_node_pool(
914 &config,
915 &upgrade_event_attributes(
916 "other-cluster",
917 "us-central1",
918 "NODE_POOL",
919 pool_resource
920 ),
921 ),
922 None,
923 );
924 assert_eq!(
925 upgrading_node_pool(
926 &config,
927 &upgrade_event_attributes("my-cluster", "europe-west1", "NODE_POOL", pool_resource),
928 ),
929 None,
930 );
931 assert_eq!(
932 upgrading_node_pool(
933 &config,
934 &upgrade_event_attributes(
935 "my-cluster",
936 "us-central1",
937 "MASTER",
938 "projects/my-project/locations/us-central1/clusters/my-cluster",
939 ),
940 ),
941 None,
942 );
943 assert_eq!(
944 upgrading_node_pool(
945 &config,
946 &upgrade_event_attributes(
947 "my-cluster",
948 "us-central1",
949 "NODE_POOL",
950 "projects/my-project/locations/us-central1/clusters/my-cluster/nodePools/generic",
951 ),
952 ),
953 None,
954 );
955
956 let mut attributes =
958 upgrade_event_attributes("my-cluster", "us-central1", "NODE_POOL", pool_resource);
959 attributes.insert(
960 "type_url".to_owned(),
961 "type.googleapis.com/google.container.v1beta1.SecurityBulletinEvent".to_owned(),
962 );
963 assert_eq!(upgrading_node_pool(&config, &attributes), None);
964
965 let config = Config::new(
967 "projects/my-project/subscriptions/my-sub".into(),
968 "my-cluster".into(),
969 "us-central1".into(),
970 vec![],
971 )
972 .unwrap();
973 assert_eq!(
974 upgrading_node_pool(
975 &config,
976 &upgrade_event_attributes("my-cluster", "us-central1", "NODE_POOL", pool_resource),
977 ),
978 Some("materialize".to_owned()),
979 );
980 }
981}