use super::SdkResult;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::broadcast;
use tracing::info;
#[derive(Debug, Clone)]
pub struct SpotInterruption {
pub provider: CloudProvider,
pub time_remaining: Option<Duration>,
pub instance_id: Option<String>,
pub action: SpotAction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CloudProvider {
Aws,
Gcp,
Azure,
Generic,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpotAction {
Terminate,
Stop,
Hibernate,
Unknown,
}
pub struct SpotHandler {
interrupted: Arc<AtomicBool>,
tx: broadcast::Sender<SpotInterruption>,
_rx: broadcast::Receiver<SpotInterruption>,
}
impl SpotHandler {
pub fn new() -> Self {
let (tx, rx) = broadcast::channel(16);
Self {
interrupted: Arc::new(AtomicBool::new(false)),
tx,
_rx: rx,
}
}
pub fn is_interrupted(&self) -> bool {
self.interrupted.load(Ordering::SeqCst)
}
pub fn subscribe(&self) -> broadcast::Receiver<SpotInterruption> {
self.tx.subscribe()
}
pub fn trigger(&self, interruption: SpotInterruption) {
self.interrupted.store(true, Ordering::SeqCst);
let _ = self.tx.send(interruption);
}
pub async fn start_aws_monitor(&self) -> SdkResult<()> {
self.start_aws_monitor_internal().await
}
async fn start_aws_monitor_internal(&self) -> SdkResult<()> {
let interrupted = self.interrupted.clone();
let tx = self.tx.clone();
tokio::spawn(async move {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.unwrap_or_default();
let token_url = "http://169.254.169.254/latest/api/token";
let spot_url = "http://169.254.169.254/latest/meta-data/spot/instance-action";
loop {
let token = match client
.put(token_url)
.header("X-aws-ec2-metadata-token-ttl-seconds", "21600")
.send()
.await
{
Ok(resp) => resp.text().await.unwrap_or_default(),
Err(_) => {
tokio::time::sleep(Duration::from_secs(5)).await;
continue;
}
};
match client
.get(spot_url)
.header("X-aws-ec2-metadata-token", &token)
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
if let Ok(body) = resp.text().await {
info!(body = %body, "AWS spot interruption notice received");
interrupted.store(true, Ordering::SeqCst);
let interruption = SpotInterruption {
provider: CloudProvider::Aws,
time_remaining: Some(Duration::from_secs(120)), instance_id: None,
action: SpotAction::Terminate,
};
let _ = tx.send(interruption);
return; }
}
_ => {}
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
});
Ok(())
}
async fn start_gcp_monitor_internal(&self) -> SdkResult<()> {
let interrupted = self.interrupted.clone();
let tx = self.tx.clone();
tokio::spawn(async move {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.unwrap_or_default();
let preempt_url = "http://metadata.google.internal/computeMetadata/v1/instance/preempted";
loop {
match client
.get(preempt_url)
.header("Metadata-Flavor", "Google")
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
if let Ok(body) = resp.text().await {
if body.trim() == "TRUE" {
info!("GCP preemption notice received");
interrupted.store(true, Ordering::SeqCst);
let interruption = SpotInterruption {
provider: CloudProvider::Gcp,
time_remaining: Some(Duration::from_secs(30)), instance_id: None,
action: SpotAction::Terminate,
};
let _ = tx.send(interruption);
return;
}
}
}
_ => {}
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
});
Ok(())
}
async fn start_azure_monitor_internal(&self) -> SdkResult<()> {
let interrupted = self.interrupted.clone();
let tx = self.tx.clone();
tokio::spawn(async move {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.unwrap_or_default();
let events_url = "http://169.254.169.254/metadata/scheduledevents?api-version=2020-07-01";
loop {
match client
.get(events_url)
.header("Metadata", "true")
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
if let Ok(body) = resp.text().await {
if body.contains("\"EventType\":\"Preempt\"") {
info!(body = %body, "Azure spot preemption notice received");
interrupted.store(true, Ordering::SeqCst);
let interruption = SpotInterruption {
provider: CloudProvider::Azure,
time_remaining: Some(Duration::from_secs(30)),
instance_id: None,
action: SpotAction::Terminate,
};
let _ = tx.send(interruption);
return;
}
}
}
_ => {}
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
});
Ok(())
}
}
impl Default for SpotHandler {
fn default() -> Self {
Self::new()
}
}
pub async fn start_spot_monitor() -> SdkResult<SpotHandler> {
let handler = SpotHandler::new();
let _ = handler.start_aws_monitor_internal().await;
let _ = handler.start_gcp_monitor_internal().await;
let _ = handler.start_azure_monitor_internal().await;
info!("Spot interruption monitors started");
Ok(handler)
}
pub async fn wait_for_spot_interruption(handler: &SpotHandler) -> SpotInterruption {
let mut rx = handler.subscribe();
rx.recv().await.unwrap_or(SpotInterruption {
provider: CloudProvider::Generic,
time_remaining: None,
instance_id: None,
action: SpotAction::Unknown,
})
}
pub async fn is_spot_instance() -> bool {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(1))
.build()
{
Ok(c) => c,
Err(_) => return false,
};
if client
.get("http://169.254.169.254/latest/meta-data/spot/instance-action")
.send()
.await
.map(|r| r.status().as_u16() != 404)
.unwrap_or(false)
{
return true;
}
if client
.get("http://metadata.google.internal/computeMetadata/v1/instance/scheduling/preemptible")
.header("Metadata-Flavor", "Google")
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
{
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_spot_handler_creation() {
let handler = SpotHandler::new();
assert!(!handler.is_interrupted());
}
#[test]
fn test_manual_trigger() {
let handler = SpotHandler::new();
handler.trigger(SpotInterruption {
provider: CloudProvider::Aws,
time_remaining: Some(Duration::from_secs(120)),
instance_id: Some("i-1234567890abcdef0".to_string()),
action: SpotAction::Terminate,
});
assert!(handler.is_interrupted());
}
}