Skip to main content

mz_orchestratord/
gcp_node_upgrade.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//! Triggers rollouts of Materialize instances when GKE upgrades the node
11//! pools they are running on.
12//!
13//! GKE automatically upgrades node pools (e.g. to roll out new node images),
14//! and this cannot be disabled. With the blue-green upgrade strategy, GKE
15//! first creates replacement (green) nodes, then cordons all of the existing
16//! (blue) nodes, then drains them in batches (respecting pod disruption
17//! budgets), and finally deletes them after a soak period of up to seven
18//! days. Left alone, this would evict or force-delete environmentd and
19//! clusterd pods, causing an outage.
20//!
21//! This module instead moves the pods with the standard graceful rollout
22//! machinery before GKE gets around to deleting the nodes:
23//!
24//!   * A Pub/Sub subscriber listens for GKE cluster notifications
25//!     (`UpgradeEvent`s) and *arms* a node pool when it starts upgrading.
26//!     Since notifications can be missed (e.g. while orchestratord is
27//!     restarting), the GKE API is additionally polled at startup and
28//!     periodically thereafter, arming any watched pool with an upgrade in
29//!     progress.
30//!
31//!   * While a pool is armed, its blue-green upgrade phase is polled from
32//!     the GKE API. Once the phase reports that *all* blue nodes have been
33//!     cordoned (`DRAINING_BLUE_POOL` or later), each Materialize instance
34//!     with environmentd or clusterd pods on the cordoned nodes gets a
35//!     forced rollout, triggered by setting the
36//!     `materialize.cloud/force-rollout` annotation on the v1 Materialize
37//!     resource. The new generation of pods can only be scheduled onto the
38//!     green nodes (the blue nodes are unschedulable), and the old
39//!     generation is torn down gracefully once the new one is ready.
40//!
41//! Arming on upgrade notifications rather than triggering on any cordon
42//! avoids spurious (and expensive) rollouts when a node is cordoned for
43//! reasons that don't mean the node is going away, e.g. an administrator
44//! debugging a node. Waiting for the cordoning phase to complete before
45//! triggering ensures the new generation cannot be scheduled onto a blue
46//! node that simply hadn't been cordoned *yet* and will still be drained.
47
48use 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
67/// The node label GKE uses to record which node pool a node belongs to.
68const GKE_NODE_POOL_LABEL: &str = "cloud.google.com/gke-nodepool";
69
70/// Label selector matching the pods which must be moved before their node
71/// goes away: environmentd and clusterd pods, which are moved between
72/// generations by the rollout machinery. Balancerd and console pods are
73/// stateless deployments and can be drained normally.
74const DATA_PLANE_POD_SELECTOR: &str = "app.kubernetes.io/name in (environmentd,clusterd),materialize.cloud/organization-name,materialize.cloud/organization-namespace";
75
76/// Safety valve: disarm pools which have been armed for longer than this.
77/// GKE caps the total soak time of a blue-green upgrade at seven days, so a
78/// pool armed for longer than this is not going to see any more drains from
79/// the upgrade that armed it.
80const MAX_ARMED_DURATION: Duration = Duration::from_secs(14 * 24 * 60 * 60);
81
82#[derive(Debug, Clone)]
83pub struct Config {
84    /// The Pub/Sub subscription receiving GKE cluster notifications, in
85    /// `projects/{project}/subscriptions/{subscription}` form.
86    pub notification_subscription: String,
87    /// The name of the GKE cluster this orchestratord is running in.
88    pub cluster_name: String,
89    /// The location (region or zone) of the GKE cluster.
90    pub cluster_location: String,
91    /// The node pools to watch. When empty, all node pools are watched.
92    pub watched_node_pools: Vec<String>,
93    /// How often to check armed node pools for progress.
94    pub scan_interval: Duration,
95    /// How often to poll the GKE API for upgrades in progress, to catch
96    /// missed notifications.
97    pub gke_poll_interval: Duration,
98    /// Minimum time between consecutive rollout triggers for the same
99    /// instance.
100    ///
101    /// A rollout in progress already suppresses re-triggering, but the
102    /// in-progress signal is only visible once the instance's status has
103    /// been updated by the reconcile loop; the cooldown covers that gap.
104    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/// The blue-green upgrade phase of a node pool, from the GKE API.
155///
156/// `WAITING_TO_DRAIN_BLUE_POOL` (the wait window of autoscaled blue-green
157/// upgrades, between cordoning and draining) is only reported by the
158/// `v1beta1` API, which is why this module talks to that version.
159#[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    /// A phase this version of orchestratord doesn't know about.
172    #[serde(other)]
173    Unknown,
174}
175
176impl BlueGreenPhase {
177    /// Whether every blue node is guaranteed to have been cordoned, meaning
178    /// a new generation of pods cannot land on a node which is about to be
179    /// drained.
180    ///
181    /// `WAITING_TO_DRAIN_BLUE_POOL` is the ideal trigger window: all blue
182    /// nodes are cordoned but GKE won't start draining them until the
183    /// configured wait (up to 7 days) elapses.
184    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
216/// Runs the GCP node upgrade watcher forever. Errors are logged and retried.
217///
218/// Never returns. Dropping this future aborts all of its work, which it must,
219/// since it triggers rollouts and only the replica holding the leadership
220/// lease may do that. The abort is not synchronous with the drop: a task
221/// caught mid-poll runs until its next await point.
222pub 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    // The two loops run as separate tasks, so that they are scheduled
236    // independently and neither can hold the other's poll up. Their handles
237    // are held here and abort the tasks when dropped, so neither loop
238    // outlives this future. A subscriber that did would keep pulling
239    // notifications, and acking them, out from under whichever replica holds
240    // the lease next.
241    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
256/// Polls the armed node pools, and the GKE API for pools that should be
257/// armed, triggering rollouts for the instances on pools whose blue nodes
258/// have all been cordoned. Never returns.
259async 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        // Poll the GKE API for upgrades in progress, to catch notifications
273        // that were missed (e.g. published while orchestratord was down).
274        // The first poll happens immediately at startup.
275        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
327/// Checks the upgrade progress of a single armed node pool, triggering
328/// rollouts for affected instances once the blue pool is fully cordoned.
329async 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        // No blue-green upgrade is in progress (any longer). Either the
361        // upgrade completed, or this pool doesn't use the blue-green
362        // strategy, in which case we can't protect it: nodes are drained
363        // immediately as they're replaced, so there is no window in which
364        // to move the pods gracefully.
365        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        // GKE is restoring the blue pool; the nodes are being uncordoned,
375        // so there is nothing to move away from. Stay armed in case the
376        // rollback is followed by another upgrade attempt (the phase goes
377        // back to an earlier value in that case).
378        return Ok(PoolCheckOutcome::StillUpgrading);
379    }
380    if !phase.blue_pool_fully_cordoned() {
381        // Blue nodes may still be schedulable; triggering a rollout now
382        // could schedule the new generation onto a blue node that just
383        // hasn't been cordoned yet. Wait for the cordoning phase to
384        // complete.
385        return Ok(PoolCheckOutcome::StillUpgrading);
386    }
387
388    // All blue nodes are cordoned. Find Materialize instances with data
389    // plane pods on them and trigger rollouts.
390    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    // Pods which are already terminating are ignored: the old generation's
415    // pods are deleted asynchronously after a rollout completes and must not
416    // re-trigger another rollout.
417    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
461/// Triggers a forced rollout of the given Materialize instance, unless one
462/// is already in progress or was triggered very recently.
463async 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    // Trigger via the force-rollout annotation rather than a spec field:
507    // spec fields are typically managed by infrastructure-as-code tools
508    // (e.g. Terraform), which would fight over any value we write there.
509    // The annotation feeds into the v1 rollout hash, and this patch goes
510    // through the v1 endpoint, so the conversion webhook re-derives the
511    // stored `requestRollout` from the new hash when the patch is applied.
512    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
529/// A minimal client for the GCP REST APIs used here (Pub/Sub and GKE),
530/// authenticating via Application Default Credentials (in particular, GKE
531/// workload identity).
532struct 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
601/// Returns the watched node pools which the GKE API reports as having an
602/// upgrade in progress.
603async 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            // RECONCILING covers upgrades in general; blueGreenInfo is
642            // present for the whole lifetime of a blue-green upgrade,
643            // including the soak phase (when the pool status may have
644            // returned to RUNNING but cordoned blue nodes still exist).
645            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
655/// Pulls GKE cluster notifications from the Pub/Sub subscription forever,
656/// arming node pools when they start upgrading.
657async 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            // An empty response indicates the (long) poll timed out without
704            // any messages arriving; pull again immediately.
705            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            // The messages will be redelivered; arming is idempotent.
728            warn!("failed to acknowledge GKE cluster notifications: {e:#}");
729        }
730    }
731}
732
733/// If the given Pub/Sub message attributes describe an `UpgradeEvent` for a
734/// watched node pool of our cluster, returns the node pool name.
735fn 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    // The topic is created per-cluster by our terraform modules, but nothing
751    // prevents other clusters from sharing it, so check that the event is
752    // for our cluster.
753    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    // `resource` is of the form
769    // projects/{project}/locations/{location}/clusters/{cluster}/nodePools/{pool}.
770    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            // Phases from future API versions must not fail parsing.
865            ("\"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            // Cordoning is in progress but not necessarily complete: some
878            // blue nodes may still accept pods.
879            (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        // The happy path: a node pool upgrade event for a watched pool.
902        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        // Events for other clusters, other resource types, and unwatched
911        // pools are ignored.
912        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        // Non-UpgradeEvent notifications are ignored.
957        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        // A config with no watched pools watches everything.
966        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}