//! The hyperhive pin the controller last sent the hives to build from. //! //! One key ([`KEY`]) in [`BUCKET`], which only this daemon reads or writes. //! The `UpdateMeta` node fans out whenever the pin `internal/meta` holds is //! not the one recorded here, and the `RecordFanout` node writes it once every //! `TriggerDeploy` of that fan-out is done or skipped by its build gate. A pass //! that dies, or a deploy that fails, before then leaves the record stale, so //! the next pass sends the deploys again. A deploy the gate skipped counts as //! sent: `jobq::queue_meta_update` declares it again when its build gave no //! verdict, and a red build waits for the next config merge. A deploy is //! idempotent per pin, so sending twice is safe and sending never is not. //! //! With no record, as on the first pass of a controller that has never written //! one, the pin is not the recorded one and the agents are sent a deploy. use std::future::Future; use anyhow::{Context as _, Result}; use async_nats::jetstream::kv::Store; /// The bucket holding the record. Agents hold no grant on it. pub(crate) const BUCKET: &str = "meta-fanout"; /// The one key in [`BUCKET`]: the pin, as a rev string. const KEY: &str = "pin"; /// Where the record lives: the bucket in production, a fake in the tests. pub(crate) trait PinStore { /// The recorded pin; `None` when none was ever written. fn recorded(&self) -> impl Future>> + Send; fn record(&self, pin: &str) -> impl Future> + Send; } impl PinStore for Store { async fn recorded(&self) -> Result> { let entry = self .get(KEY) .await .with_context(|| format!("read {BUCKET}/{KEY}"))?; Ok(entry.map(|b| String::from_utf8_lossy(&b).into_owned())) } async fn record(&self, pin: &str) -> Result<()> { self.put(KEY, pin.to_owned().into()) .await .map(|_| ()) .with_context(|| format!("write {BUCKET}/{KEY}")) } } /// Open [`BUCKET`], creating it if nothing has yet. async fn open(client: &async_nats::Client) -> Result { let js = async_nats::jetstream::new(client.clone()); if let Ok(store) = js.get_key_value(BUCKET).await { return Ok(store); } js.create_key_value(async_nats::jetstream::kv::Config { bucket: BUCKET.to_owned(), description: "The hyperhive pin the controller last sent the hives to build from" .to_owned(), history: 1, ..Default::default() }) .await .with_context(|| format!("creating the {BUCKET} bucket")) } /// The pin recorded in the bucket on `client`, opening or creating it. /// /// # Errors /// The bucket cannot be opened or created, or its key cannot be read. pub(crate) async fn recorded(client: &async_nats::Client) -> Result> { open(client).await?.recorded().await } /// Record `pin` as sent, in the bucket on `client`. /// /// # Errors /// The bucket cannot be opened or created, or the key cannot be written. pub(crate) async fn record(client: &async_nats::Client, pin: &str) -> Result<()> { open(client).await?.record(pin).await } #[cfg(test)] mod tests { use super::*; /// The bucket on a real server: absent until written, then the pin /// written, and a second write replaces it. #[tokio::test] async fn the_pin_round_trips_through_the_bucket() { let nats = crate::test_nats::TestNats::start(); let client = nats.client().await; assert_eq!(recorded(&client).await.expect("opens"), None); record(&client, "aaaa").await.expect("written"); assert_eq!(recorded(&client).await.expect("read"), Some("aaaa".into())); record(&client, "bbbb").await.expect("replaced"); assert_eq!(recorded(&client).await.expect("read"), Some("bbbb".into())); } }