Skip to main content

areev_trigger/
render.rs

1//! `areev trigger render` — emit scheduler config, never create it.
2//!
3//! These are **templates, not API clients**. Areev does not hold cloud
4//! credentials and does not create cloud resources: provisioning a Cloud
5//! Scheduler job or an EventBridge rule needs IAM permission to create
6//! resources, which is a large privilege escalation for a memory engine, and it
7//! buys a multi-cloud abstraction treadmill for very little differentiation.
8//! You apply the output with whatever you already use for infrastructure.
9//!
10//! ## The heartbeat is deliberately coarse
11//!
12//! The rendered interval is the greatest common divisor of the declared
13//! intervals, floored at 60s — not the shortest interval. The memory owns the
14//! real cadence, so the heartbeat only has to be fine enough not to add
15//! meaningful lag. Rendering a 30-second cron because one trigger asked for 30
16//! seconds would put the schedule back in the crontab, which is the thing this
17//! feature exists to stop.
18
19use areev_core::types::{Trigger, TriggerKind};
20
21use crate::error::{Result, TriggerError};
22
23/// Never render a heartbeat finer than this. cron cannot express sub-minute
24/// schedules at all, and a finer systemd timer mostly buys wakeups.
25const MIN_HEARTBEAT_SECS: u64 = 60;
26
27/// Pick the heartbeat for a set of declarations.
28pub 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            // A cron trigger's cadence is not a period, so it cannot contribute
35            // a divisor. The floor covers it: cron has minute resolution.
36            _ => 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
53/// What the rendered config should invoke.
54pub struct RenderContext<'a> {
55    pub exe: &'a str,
56    pub db: &'a str,
57    pub ns: &'a str,
58    pub heartbeat_secs: u64,
59    /// Extra flags to pass through, e.g. `--connector-cmd ./gmail.sh`.
60    pub extra_args: &'a str,
61}
62
63/// What `command[0]` must be inside a container image.
64///
65/// The host targets (`cron`, `launchd`, `systemd`) genuinely run on the machine
66/// that produced the render, so the authoring binary's absolute path is right
67/// there. A container runs the *image's* binary, so the only correct value is
68/// the name on `PATH` inside it — `current_exe()` is the authoring host's path
69/// and is guaranteed wrong.
70const IMAGE_EXE: &str = "areev";
71
72impl RenderContext<'_> {
73    fn command(&self) -> String {
74        self.command_as(self.exe)
75    }
76
77    /// [`command`](Self::command) for the container targets. See [`IMAGE_EXE`].
78    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
92/// Render config for `target`.
93pub 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
103/// Every target `render` accepts.
104pub const TARGETS: &[&str] = &["cron", "launchd", "systemd", "k8s-cronjob"];
105
106fn cron_expression(secs: u64) -> String {
107    // cron has minute resolution and no "every N seconds".
108    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        // A period that does not divide an hour would drift or double-fire at
117        // the wrap. Round down to a divisor rather than emit something subtly
118        // wrong — the memory decides what is due anyway, so a finer heartbeat
119        // is harmless and a wrong one is not.
120        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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
249}
250
251/// Quote anything that is not an unambiguous YAML plain scalar.
252fn 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        // 120 and 300 share 60. Rendering 120 would be finer than needed;
286        // rendering 300 would make the 120s trigger late.
287        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        // Sub-minute is inexpressible in cron and mostly buys wakeups. The
294        // memory decides what is due, so a coarse heartbeat is the point.
295        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        // A cron cadence is not a period, so it cannot be gcd'd. The floor
310        // covers it, since cron has minute resolution anyway.
311        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        // 7 minutes does not divide an hour — it would drift at the wrap, so it
326        // rounds DOWN to something that does rather than emitting a schedule
327        // that is subtly wrong.
328        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            // launchd splits argv into <string> elements, so the tokens are
340            // asserted individually rather than as one substring.
341            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    /// A container runs the image's binary, never the authoring host's.
348    ///
349    /// The regression (#69) shipped because the test context used
350    /// `exe: "areev"` — the same string the fix produces — so a render that
351    /// spliced in `current_exe()` looked identical to one that did not. This
352    /// context uses a path that could only have come from the authoring
353    /// machine, which is the whole defect.
354    #[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        // …while the host targets genuinely run on the machine that rendered
376        // them, so for them the absolute path is the correct answer.
377        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        // A leading dash starts a YAML sequence entry; unquoted `--db` inside a
403        // list item is a parse hazard.
404        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        // Persistent=true is the systemd spelling of the catch-up posture the
412        // evaluator already takes.
413        assert!(out.contains("Persistent=true"));
414        // Deterministic rather than random, so behaviour stays reproducible.
415        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}