wowlab_sentinel/scheduler/
burst.rs1#![expect(
4 clippy::cast_possible_truncation,
5 clippy::cast_precision_loss,
6 clippy::cast_sign_loss,
7 reason = "burst sizing intentionally rounds bounded database counts through floating-point arithmetic"
8)]
9
10use std::{
11 sync::atomic::Ordering,
12 time::{Duration, Instant},
13};
14
15use async_trait::async_trait;
16use tokio::sync::Mutex;
17
18use crate::{
19 ExposeSecret,
20 cron::CronJob,
21 latitude::{LatitudeClient, LatitudeError, ProvisionSpec},
22 state::ServerState,
23};
24
25const BURST_PLAN: &str = "m4-metal-xlarge";
26const BURST_OS: &str = "ubuntu_24_04";
27const BURST_HOSTNAME: &str = "wowlab-burst";
28const SCALE_DOWN_HYSTERESIS: Duration = Duration::from_secs(15 * 60);
29const MIN_NODE_AGE_BEFORE_KILL: Duration = Duration::from_secs(55 * 60);
30
31#[derive(Clone, Debug)]
32struct BurstNode {
33 server_id: String,
34 started_at: Instant,
35 below_target_since: Option<Instant>,
36}
37
38#[derive(Debug)]
39pub(crate) struct BurstScheduler {
40 schedule: String,
41 nodes: Mutex<Vec<BurstNode>>,
42}
43
44struct ReconcileCtx<'a> {
45 latitude: &'a LatitudeClient,
46 project: &'a str,
47 site: &'a str,
48 user_data_b64: &'a str,
49 tag_ids: &'a [&'a str],
50}
51
52impl BurstScheduler {
53 pub(crate) fn new(schedule: &str) -> Self {
54 Self {
55 schedule: schedule.to_string(),
56 nodes: Mutex::new(Vec::new()),
57 }
58 }
59}
60
61fn compute_target(pending: i64, floor_capacity: i64, per_node: i64) -> usize {
62 let overflow = pending.saturating_sub(floor_capacity);
63
64 if overflow <= 0 || per_node <= 0 {
65 return 0;
66 }
67
68 ((overflow as f64) / (per_node as f64)).ceil() as usize
69}
70
71#[async_trait]
72impl CronJob for BurstScheduler {
73 fn name(&self) -> &'static str {
74 "burst_scheduler"
75 }
76
77 fn schedule(&self) -> &str {
78 &self.schedule
79 }
80
81 async fn run(&self, state: &ServerState) {
82 if !state.config.burst_enabled {
83 return;
84 }
85
86 let Some(latitude) = state.latitude.as_ref() else {
87 tracing::debug!("Burst enabled but no Latitude client configured");
88
89 return;
90 };
91 let Some(project) = state.config.latitude_project.as_deref() else {
92 tracing::warn!("Burst enabled but LATITUDE_PROJECT not set");
93
94 return;
95 };
96 let Some(site) = state.config.latitude_site.as_deref() else {
97 tracing::warn!("Burst enabled but LATITUDE_SITE not set");
98
99 return;
100 };
101 let Some(claim_token_secret) = state.config.burst_node_claim_token.as_ref() else {
102 tracing::warn!("Burst enabled but BURST_NODE_CLAIM_TOKEN not set");
103
104 return;
105 };
106 let Some(ts_authkey_secret) = state.config.burst_ts_authkey.as_ref() else {
107 tracing::warn!("Burst enabled but BURST_TS_AUTHKEY not set");
108
109 return;
110 };
111
112 let pending = state.runtime.pending_chunks.load(Ordering::Relaxed);
113 let target = compute_target(
114 pending,
115 state.config.burst_floor_capacity,
116 state.config.burst_per_node_throughput,
117 )
118 .min(state.config.burst_max_nodes);
119
120 let user_data = render_user_data(
121 BURST_HOSTNAME,
122 claim_token_secret.expose_secret(),
123 ts_authkey_secret.expose_secret(),
124 );
125 let tag_ids: Vec<&str> = state
126 .config
127 .latitude_burst_tag_id
128 .as_deref()
129 .into_iter()
130 .collect();
131
132 let ctx = ReconcileCtx {
133 latitude,
134 project,
135 site,
136 user_data_b64: &user_data,
137 tag_ids: &tag_ids,
138 };
139
140 let mut nodes = self.nodes.lock().await;
141
142 if let Err(e) = reconcile(&mut nodes, target, &ctx).await {
143 tracing::warn!(error = %e, "Burst reconcile failed");
144 }
145 }
146}
147
148async fn reconcile(
149 nodes: &mut Vec<BurstNode>,
150 target: usize,
151 ctx: &ReconcileCtx<'_>,
152) -> Result<(), LatitudeError> {
153 let now = Instant::now();
154
155 if target > nodes.len() {
156 let to_add = target - nodes.len();
157
158 for _ in 0..to_add {
159 match provision_node(ctx, now).await {
160 Ok(node) => nodes.push(node),
161 Err(error) => {
162 if let Some(server_id) = error.stranded_server_id() {
163 nodes.push(BurstNode {
164 server_id: server_id.to_owned(),
165 started_at: now,
166 below_target_since: None,
167 });
168 }
169
170 return Err(error);
171 }
172 }
173 }
174
175 return Ok(());
176 }
177
178 if target < nodes.len() {
179 mark_below_target(nodes, now);
180 let needed = nodes.len() - target;
181 let killable: Vec<String> = nodes
182 .iter()
183 .filter(|n| {
184 n.below_target_since
185 .is_some_and(|t| now.duration_since(t) > SCALE_DOWN_HYSTERESIS)
186 && now.duration_since(n.started_at) > MIN_NODE_AGE_BEFORE_KILL
187 })
188 .take(needed)
189 .map(|n| n.server_id.clone())
190 .collect();
191
192 let mut deprovisioned = Vec::with_capacity(killable.len());
193
194 for id in killable {
195 if deprovision_node(ctx.latitude, &id).await.is_ok() {
196 deprovisioned.push(id);
197 }
198 }
199
200 nodes.retain(|n| !deprovisioned.contains(&n.server_id));
201
202 return Ok(());
203 }
204
205 clear_below_target(nodes);
206
207 Ok(())
208}
209
210async fn provision_node(ctx: &ReconcileCtx<'_>, now: Instant) -> Result<BurstNode, LatitudeError> {
211 let spec = ProvisionSpec {
212 project: ctx.project,
213 plan: BURST_PLAN,
214 site: ctx.site,
215 operating_system: BURST_OS,
216 hostname: BURST_HOSTNAME,
217 user_data_b64: ctx.user_data_b64,
218 tag_ids: ctx.tag_ids,
219 };
220 let server = ctx.latitude.provision(&spec).await?;
221
222 tracing::info!(server_id = %server.id, "Provisioned burst node");
223
224 Ok(BurstNode {
225 server_id: server.id,
226 started_at: now,
227 below_target_since: None,
228 })
229}
230
231fn mark_below_target(nodes: &mut [BurstNode], now: Instant) {
232 for node in nodes {
233 if node.below_target_since.is_none() {
234 node.below_target_since = Some(now);
235 }
236 }
237}
238
239async fn deprovision_node(latitude: &LatitudeClient, server_id: &str) -> Result<(), LatitudeError> {
240 match latitude.deprovision(server_id).await {
241 Ok(()) => {
242 tracing::info!(server_id, "Deprovisioned burst node");
243
244 Ok(())
245 }
246 Err(error) => {
247 tracing::warn!(server_id, %error, "Burst node deprovision failed");
248
249 Err(error)
250 }
251 }
252}
253
254fn clear_below_target(nodes: &mut [BurstNode]) {
255 for node in nodes {
256 node.below_target_since = None;
257 }
258}
259
260fn render_user_data(hostname: &str, claim_token: &str, ts_authkey: &str) -> String {
262 use base64::Engine;
263 let template = include_str!("../../../../deploy/node/latitude-userdata.yaml.tmpl");
264 let rendered = template
265 .replace("__NODE_NAME__", hostname)
266 .replace("__CLAIM_TOKEN__", claim_token)
267 .replace("__TS_AUTHKEY__", ts_authkey);
268 base64::engine::general_purpose::STANDARD.encode(rendered.as_bytes())
269}
270#[cfg(test)]
273mod tests {
274 use base64::Engine;
275 use googletest::prelude::*;
276 use secrecy::SecretString;
277 use wiremock::{
278 Mock, MockServer, ResponseTemplate,
279 matchers::{method, path},
280 };
281
282 use super::*;
283
284 #[gtest]
285 fn target_zero_when_pending_below_floor() -> Result<()> {
286 verify_eq!(compute_target(100, 200, 50), 0)?;
287
288 Ok(())
289 }
290
291 #[gtest]
292 fn target_ceil_of_overflow_over_per_node() -> Result<()> {
293 verify_eq!(compute_target(250, 100, 50), 3)?;
294 verify_eq!(compute_target(251, 100, 50), 4)?;
295
296 Ok(())
297 }
298
299 #[gtest]
300 fn target_zero_when_per_node_zero() -> Result<()> {
301 verify_eq!(compute_target(500, 0, 0), 0)?;
302
303 Ok(())
304 }
305
306 #[gtest]
307 fn user_data_resolves_every_cloud_init_placeholder() -> Result<()> {
308 let encoded = render_user_data("burst-01", "claim-secret", "tailnet-secret");
309 let bytes = base64::engine::general_purpose::STANDARD
310 .decode(encoded)
311 .or_fail()?;
312 let rendered = String::from_utf8(bytes).or_fail()?;
313
314 verify_true!(rendered.contains("NODE_NAME=burst-01"))?;
315 verify_true!(rendered.contains("NODE_CLAIM_TOKEN=claim-secret"))?;
316 verify_true!(rendered.contains("--authkey=tailnet-secret"))?;
317 verify_true!(!rendered.contains("__NODE_NAME__"))?;
318 verify_true!(!rendered.contains("__CLAIM_TOKEN__"))?;
319 verify_true!(!rendered.contains("__TS_AUTHKEY__"))?;
320
321 Ok(())
322 }
323
324 #[gtest]
325 #[tokio::test]
326 async fn failed_deprovision_keeps_node_tracked_for_retry() -> Result<()> {
327 let mock = MockServer::start().await;
328
329 Mock::given(method("DELETE"))
330 .and(path("/servers/sv_retry"))
331 .respond_with(ResponseTemplate::new(500))
332 .expect(1)
333 .mount(&mock)
334 .await;
335
336 let latitude = LatitudeClient::new(SecretString::from("test-token"), mock.uri());
337 let now = Instant::now();
338 let old_enough = MIN_NODE_AGE_BEFORE_KILL + Duration::from_secs(1);
339 let below_long_enough = SCALE_DOWN_HYSTERESIS + Duration::from_secs(1);
340 let started_at = now.checked_sub(old_enough).or_fail()?;
341 let below_target_since = now.checked_sub(below_long_enough).or_fail()?;
342 let mut nodes = vec![BurstNode {
343 server_id: "sv_retry".to_string(),
344 started_at,
345 below_target_since: Some(below_target_since),
346 }];
347 let ctx = ReconcileCtx {
348 latitude: &latitude,
349 project: "project",
350 site: "site",
351 user_data_b64: "user-data",
352 tag_ids: &[],
353 };
354
355 reconcile(&mut nodes, 0, &ctx).await.or_fail()?;
356
357 verify_that!(nodes, len(eq(1)))?;
358
359 verify_that!(nodes[0].server_id.as_str(), eq("sv_retry"))
360 }
361
362 #[gtest]
363 #[tokio::test]
364 async fn failed_provision_cleanup_keeps_created_server_tracked() -> Result<()> {
365 let mock = MockServer::start().await;
366
367 Mock::given(method("POST"))
368 .and(path("/servers"))
369 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
370 "data": { "type": "servers", "id": "sv_stranded", "attributes": {} }
371 })))
372 .mount(&mock)
373 .await;
374 Mock::given(method("PATCH"))
375 .and(path("/servers/sv_stranded"))
376 .respond_with(ResponseTemplate::new(500))
377 .mount(&mock)
378 .await;
379 Mock::given(method("DELETE"))
380 .and(path("/servers/sv_stranded"))
381 .respond_with(ResponseTemplate::new(500))
382 .mount(&mock)
383 .await;
384
385 let latitude = LatitudeClient::new(SecretString::from("test-token"), mock.uri());
386 let mut nodes = Vec::new();
387 let ctx = ReconcileCtx {
388 latitude: &latitude,
389 project: "project",
390 site: "site",
391 user_data_b64: "user-data",
392 tag_ids: &["tag"],
393 };
394
395 verify_that!(reconcile(&mut nodes, 1, &ctx).await, err(anything()))?;
396 verify_that!(nodes, len(eq(1)))?;
397
398 verify_that!(nodes[0].server_id.as_str(), eq("sv_stranded"))
399 }
400}