1use areev_core::types::{Trigger, TriggerKind};
20
21use crate::error::{Result, TriggerError};
22
23const MIN_HEARTBEAT_SECS: u64 = 60;
26
27pub fn heartbeat_secs(triggers: &[Trigger]) -> u64 {
29 let intervals: Vec<u64> = triggers
30 .iter()
31 .filter(|t| t.enabled)
32 .filter_map(|t| match t.kind {
33 TriggerKind::Interval | TriggerKind::Polling => t.interval_secs.map(u64::from),
34 _ => None,
37 })
38 .filter(|s| *s > 0)
39 .collect();
40
41 let g = intervals.into_iter().reduce(gcd).unwrap_or(MIN_HEARTBEAT_SECS);
42 g.max(MIN_HEARTBEAT_SECS)
43}
44
45fn gcd(a: u64, b: u64) -> u64 {
46 if b == 0 {
47 a
48 } else {
49 gcd(b, a % b)
50 }
51}
52
53pub struct RenderContext<'a> {
55 pub exe: &'a str,
56 pub db: &'a str,
57 pub ns: &'a str,
58 pub heartbeat_secs: u64,
59 pub extra_args: &'a str,
61}
62
63const IMAGE_EXE: &str = "areev";
71
72impl RenderContext<'_> {
73 fn command(&self) -> String {
74 self.command_as(self.exe)
75 }
76
77 fn container_command(&self) -> String {
79 self.command_as(IMAGE_EXE)
80 }
81
82 fn command_as(&self, exe: &str) -> String {
83 let mut c = format!("{} trigger run --db {} --ns {}", exe, self.db, self.ns);
84 if !self.extra_args.trim().is_empty() {
85 c.push(' ');
86 c.push_str(self.extra_args.trim());
87 }
88 c
89 }
90}
91
92pub fn render(target: &str, ctx: &RenderContext<'_>) -> Result<String> {
94 match target {
95 "cron" => Ok(cron(ctx)),
96 "launchd" => Ok(launchd(ctx)),
97 "systemd" => Ok(systemd(ctx)),
98 "k8s-cronjob" => Ok(k8s(ctx)),
99 other => Err(TriggerError::UnknownTarget { target: other.to_string() }),
100 }
101}
102
103pub const TARGETS: &[&str] = &["cron", "launchd", "systemd", "k8s-cronjob"];
105
106fn cron_expression(secs: u64) -> String {
107 let mins = (secs / 60).max(1);
109 if mins == 1 {
110 "* * * * *".to_string()
111 } else if mins < 60 && 60 % mins == 0 {
112 format!("*/{mins} * * * *")
113 } else if mins.is_multiple_of(60) && (mins / 60) < 24 {
114 format!("0 */{} * * *", mins / 60)
115 } else {
116 let divisor = [30u64, 20, 15, 12, 10, 6, 5, 4, 3, 2, 1]
121 .into_iter()
122 .find(|d| *d <= mins)
123 .unwrap_or(1);
124 format!("*/{divisor} * * * *")
125 }
126}
127
128fn cron(ctx: &RenderContext<'_>) -> String {
129 format!(
130 "# areev triggers — the memory owns the real cadence; this is only a heartbeat.\n\
131 # Generated by `areev trigger render --target cron`. Apply with `crontab -e`.\n\
132 {} {}\n",
133 cron_expression(ctx.heartbeat_secs),
134 ctx.command()
135 )
136}
137
138fn launchd(ctx: &RenderContext<'_>) -> String {
139 let args: Vec<String> = ctx.command().split_whitespace().map(String::from).collect();
140 let arg_xml: String = args
141 .iter()
142 .map(|a| format!(" <string>{}</string>\n", xml_escape(a)))
143 .collect();
144 format!(
145 r#"<?xml version="1.0" encoding="UTF-8"?>
146<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
147<!-- areev triggers heartbeat. The memory owns the real cadence. -->
148<plist version="1.0">
149<dict>
150 <key>Label</key>
151 <string>ai.areev.triggers</string>
152 <key>ProgramArguments</key>
153 <array>
154{arg_xml} </array>
155 <key>StartInterval</key>
156 <integer>{secs}</integer>
157 <key>RunAtLoad</key>
158 <true/>
159</dict>
160</plist>
161"#,
162 secs = ctx.heartbeat_secs
163 )
164}
165
166fn systemd(ctx: &RenderContext<'_>) -> String {
167 format!(
168 r#"# areev triggers heartbeat. The memory owns the real cadence.
169# Save as areev-triggers.service and areev-triggers.timer, then:
170# systemctl --user enable --now areev-triggers.timer
171
172# ---- areev-triggers.service ----
173[Unit]
174Description=Areev trigger evaluation
175
176[Service]
177Type=oneshot
178ExecStart={cmd}
179
180# ---- areev-triggers.timer ----
181[Unit]
182Description=Areev trigger heartbeat
183
184[Timer]
185OnBootSec={secs}s
186OnUnitActiveSec={secs}s
187# Fire on resume if the machine was asleep through a window — the same
188# catch-up posture the evaluator itself takes.
189Persistent=true
190# Spread wakeups deterministically rather than randomly, so behaviour stays
191# reproducible.
192FixedRandomDelay=true
193RandomizedDelaySec={jitter}s
194
195[Install]
196WantedBy=timers.target
197"#,
198 cmd = ctx.command(),
199 secs = ctx.heartbeat_secs,
200 jitter = (ctx.heartbeat_secs / 10).max(1),
201 )
202}
203
204fn k8s(ctx: &RenderContext<'_>) -> String {
205 let args: Vec<String> = ctx.container_command().split_whitespace().map(String::from).collect();
206 let arg_yaml: String =
207 args.iter().map(|a| format!(" - {}\n", yaml_scalar(a))).collect();
208 format!(
209 r#"# areev triggers heartbeat. The memory owns the real cadence.
210apiVersion: batch/v1
211kind: CronJob
212metadata:
213 name: areev-triggers
214spec:
215 schedule: "{schedule}"
216 # Forbid: a heartbeat that overlaps itself would have several evaluators
217 # racing for the same claims. They would be safe — losers skip — but it is
218 # wasted work, and the evaluator's own concurrency policy is the right place
219 # to express overlap, per trigger.
220 concurrencyPolicy: Forbid
221 # A tick this late is not worth firing: the memory will decide what is due on
222 # the next one anyway.
223 startingDeadlineSeconds: {deadline}
224 successfulJobsHistoryLimit: 1
225 failedJobsHistoryLimit: 3
226 jobTemplate:
227 spec:
228 template:
229 spec:
230 restartPolicy: OnFailure
231 containers:
232 - name: areev
233 # `command` runs THIS image's binary, so it is the name on PATH in
234 # the image — not a path from the machine that rendered this.
235 image: areev:latest
236 command:
237{arg_yaml} # --db above is the path as written when this was
238 # rendered. Mount the memory and make that path resolve INSIDE the
239 # container; a heartbeat pointed at a path that does not exist
240 # fails every tick, which looks exactly like nothing being due.
241"#,
242 schedule = cron_expression(ctx.heartbeat_secs),
243 deadline = (ctx.heartbeat_secs * 2).max(120),
244 )
245}
246
247fn xml_escape(s: &str) -> String {
248 s.replace('&', "&").replace('<', "<").replace('>', ">")
249}
250
251fn yaml_scalar(s: &str) -> String {
253 let plain = !s.is_empty()
254 && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))
255 && !s.starts_with('-');
256 if plain {
257 s.to_string()
258 } else {
259 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 const WF: &str = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
268
269 fn ctx(secs: u64) -> RenderContext<'static> {
270 RenderContext {
271 exe: "areev",
272 db: "accounting.db",
273 ns: "accounting",
274 heartbeat_secs: secs,
275 extra_args: "--connector-cmd ./gmail.sh",
276 }
277 }
278
279 fn interval(secs: u32) -> Trigger {
280 Trigger::new(TriggerKind::Interval, WF).interval_secs(secs)
281 }
282
283 #[test]
284 fn the_heartbeat_is_the_gcd_not_the_shortest_interval() {
285 let h = heartbeat_secs(&[interval(120), interval(300)]);
288 assert_eq!(h, 60);
289 }
290
291 #[test]
292 fn the_heartbeat_never_goes_below_a_minute() {
293 assert_eq!(heartbeat_secs(&[interval(5), interval(10)]), 60);
296 }
297
298 #[test]
299 fn a_disabled_trigger_does_not_pull_the_heartbeat_down() {
300 let h = heartbeat_secs(&[
301 interval(600),
302 interval(120).enabled(false),
303 ]);
304 assert_eq!(h, 600, "a disabled trigger should not cost wakeups");
305 }
306
307 #[test]
308 fn cron_triggers_do_not_contribute_a_divisor() {
309 let h = heartbeat_secs(&[Trigger::new(TriggerKind::Schedule, WF).cron("0 9 * * *")]);
312 assert_eq!(h, 60);
313 }
314
315 #[test]
316 fn no_triggers_still_renders_a_sane_heartbeat() {
317 assert_eq!(heartbeat_secs(&[]), 60);
318 }
319
320 #[test]
321 fn cron_expressions_stay_expressible() {
322 assert_eq!(cron_expression(60), "* * * * *");
323 assert_eq!(cron_expression(300), "*/5 * * * *");
324 assert_eq!(cron_expression(3600), "0 */1 * * *");
325 let seven = cron_expression(420);
329 assert!(seven.starts_with("*/"), "{seven}");
330 let n: u64 = seven.trim_start_matches("*/").split(' ').next().unwrap().parse().unwrap();
331 assert_eq!(60 % n, 0, "the rendered period must divide an hour: {seven}");
332 }
333
334 #[test]
335 fn every_declared_target_renders() {
336 for t in TARGETS {
337 let out = render(t, &ctx(120)).unwrap_or_else(|e| panic!("{t}: {e}"));
338 assert!(!out.trim().is_empty(), "{t} rendered nothing");
339 assert!(out.contains("trigger"), "{t} must invoke the evaluator");
342 assert!(out.contains("run"), "{t} must invoke the evaluator");
343 assert!(out.contains("accounting.db"), "{t} must name the memory");
344 }
345 }
346
347 #[test]
355 fn container_targets_never_emit_the_authoring_hosts_binary_path() {
356 let local = "/Users/someone/.cargo/bin/areev";
357 let c = RenderContext {
358 exe: local,
359 db: "accounting.db",
360 ns: "accounting",
361 heartbeat_secs: 300,
362 extra_args: "",
363 };
364
365 let k8s = render("k8s-cronjob", &c).unwrap();
366 assert!(
367 !k8s.contains(local),
368 "a local path in a container spec is wrong wherever it is applied:\n{k8s}"
369 );
370 assert!(
371 k8s.contains("- areev\n"),
372 "command[0] must be the name on PATH inside the image:\n{k8s}"
373 );
374
375 for t in ["cron", "launchd", "systemd"] {
378 let out = render(t, &c).unwrap();
379 assert!(out.contains(local), "{t} runs locally and must name the local binary");
380 }
381 }
382
383 #[test]
384 fn an_unknown_target_is_refused_with_its_code() {
385 let e = render("eventbridge", &ctx(60)).unwrap_err();
386 assert_eq!(e.code(), "TRG-E007");
387 }
388
389 #[test]
390 fn launchd_output_is_well_formed_plist() {
391 let out = render("launchd", &ctx(120)).unwrap();
392 assert!(out.starts_with("<?xml"));
393 assert!(out.contains("<key>StartInterval</key>"));
394 assert!(out.contains("<integer>120</integer>"));
395 assert_eq!(out.matches("<array>").count(), out.matches("</array>").count());
396 assert_eq!(out.matches("<dict>").count(), out.matches("</dict>").count());
397 }
398
399 #[test]
400 fn k8s_quotes_arguments_that_yaml_would_misread() {
401 let out = render("k8s-cronjob", &ctx(120)).unwrap();
402 assert!(out.contains("\"--db\""), "flags must be quoted:\n{out}");
405 assert!(out.contains("concurrencyPolicy: Forbid"));
406 }
407
408 #[test]
409 fn systemd_asks_for_catch_up_and_deterministic_jitter() {
410 let out = render("systemd", &ctx(300)).unwrap();
411 assert!(out.contains("Persistent=true"));
414 assert!(out.contains("FixedRandomDelay=true"));
416 assert!(out.contains("OnUnitActiveSec=300s"));
417 }
418
419 #[test]
420 fn extra_args_reach_the_rendered_command() {
421 for t in TARGETS {
422 let out = render(t, &ctx(60)).unwrap();
423 assert!(out.contains("gmail.sh"), "{t} dropped the connector command");
424 }
425 }
426}