Skip to main content

cloud/
almanac_dispatch.rs

1//! Dispatch [`almanac::OnChangeConfig`] actions after a feed run (R330-F4).
2//!
3//! The almanac crate writes the feed artifact and returns the on_change action
4//! to its caller; this module is the caller-side implementation that knows how
5//! to map each action variant to the right reconciler.
6//!
7//! Current variants:
8//! - [`almanac::OnChangeConfig::MesofactRebuild`] → call
9//!   [`MesofactStaticReconciler::revalidate_static`] on the named service's
10//!   mirror for `env`. An almanac feed change is *data*, not a source/template
11//!   change, so this is the revalidate-only path (W225 §3, R535-T1): it never
12//!   runs the workload's `build.command` — see `revalidate_static`'s doc for
13//!   what "already-built bundle" means for each provider arm.
14//! - [`almanac::OnChangeConfig::Reload`] → nothing, deliberately. See the arm.
15
16use anyhow::{Context, Result};
17use std::path::Path;
18use yah_almanac::OnChangeConfig;
19
20use crate::config::CloudConfig;
21use crate::reconciler::{MesofactStaticReconciler, ReconcileCtx};
22
23/// Dispatch the `on_change` action from an almanac [`RunResult`].
24///
25/// `workspace_root` must be the project root (parent of `.yah/`).
26/// `env` selects which mirror environment to target (e.g. `"prod"`, `"pond"`).
27pub async fn dispatch_on_change(
28    on_change: &OnChangeConfig,
29    workspace_root: &Path,
30    env: &str,
31) -> Result<()> {
32    match on_change {
33        OnChangeConfig::MesofactRebuild { service, route } => {
34            revalidate_mesofact(service, route, workspace_root, env).await
35        }
36        // Nothing to do here, and that is the design rather than a stub.
37        //
38        // A `reload` feed's consumer reads the emitted artifact directly, so the
39        // artifact write that already happened upstream of this call IS the
40        // update — there is no render to trigger and no bundle to publish. The
41        // variant exists because `on_change` carries the feed's mirror binding
42        // as well as its action (see the enum's docs and R335-F3): without one,
43        // the receiver rejects the feed with 422 before it ever runs.
44        //
45        // A consumer that needs a genuine in-process nudge must get it from the
46        // process embedding `almanac::serve::run`, which owns the artifact and
47        // the reader. This function is the *control-plane* reconciler and cannot
48        // reach into another process's address space; pretending otherwise here
49        // would be a silent no-op instead of an explicit one.
50        OnChangeConfig::Reload { service } => {
51            tracing::info!(
52                service,
53                env,
54                "almanac on_change: reload — the artifact write is the update, \
55                 nothing to rebuild"
56            );
57            Ok(())
58        }
59    }
60}
61
62async fn revalidate_mesofact(
63    service_name: &str,
64    route: &str,
65    workspace_root: &Path,
66    env: &str,
67) -> Result<()> {
68    let config = CloudConfig::load(workspace_root)
69        .with_context(|| format!("loading cloud config from {}", workspace_root.display()))?;
70
71    let svc = config
72        .service(service_name)
73        .with_context(|| format!("service {service_name:?} not found in .yah/services/"))?;
74
75    let component = svc
76        .service
77        .components
78        .iter()
79        .find(|c| c.kind == "mesofact-static")
80        .with_context(|| format!("service {service_name:?} has no mesofact-static component"))?;
81
82    let mirror = svc
83        .mirrors
84        .get(env)
85        .with_context(|| format!("service {service_name:?} has no mirror for env {env:?}"))?;
86
87    let ctx = ReconcileCtx {
88        workspace_root,
89        service: &svc.service,
90        component,
91        mirror,
92        env,
93        scope: crate::reconciler::ProviderScope::singleton(),
94    };
95
96    let reconciler = MesofactStaticReconciler::new();
97    let result = reconciler.revalidate_static(ctx, route).await?;
98
99    tracing::info!(
100        service = service_name,
101        env,
102        public_url = ?result.public_url,
103        "almanac on_change: mesofact revalidate complete"
104    );
105    Ok(())
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use tempfile::TempDir;
112
113    fn write_toml(path: &std::path::Path, content: &str) {
114        if let Some(parent) = path.parent() {
115            std::fs::create_dir_all(parent).unwrap();
116        }
117        std::fs::write(path, content).unwrap();
118    }
119
120    #[tokio::test]
121    async fn missing_service_returns_error() {
122        let tmp = TempDir::new().unwrap();
123        let root = tmp.path();
124        // A real workspace that declares no services — CloudConfig::load
125        // returns an empty map. R844-B7: the `.yah/` is what makes this an
126        // empty fleet rather than a wrong-root error, and the distinction is
127        // the point of the test (the service is missing, not the workspace).
128        std::fs::create_dir_all(root.join(".yah")).unwrap();
129        let on_change = OnChangeConfig::MesofactRebuild {
130            service: "no-such-svc".to_string(),
131            route: "/releases".to_string(),
132        };
133        let err = dispatch_on_change(&on_change, root, "prod")
134            .await
135            .unwrap_err();
136        let msg = format!("{err:#}");
137        assert!(msg.contains("no-such-svc"), "got: {msg}");
138    }
139
140    #[tokio::test]
141    async fn missing_mirror_env_returns_error() {
142        let tmp = TempDir::new().unwrap();
143        let root = tmp.path();
144        // Write service.toml with a mesofact-static component, but no mirrors.
145        write_toml(
146            &root.join(".yah/services/dev-yah/service.toml"),
147            r#"schema_version = 1
148name = "dev-yah"
149[address]
150kind = "front-door"
151domain = "yah.dev"
152
153[[components]]
154id = "site"
155kind = "mesofact-static"
156path = "app/yah/web"
157role = "static"
158wave = 0
159"#,
160        );
161        let on_change = OnChangeConfig::MesofactRebuild {
162            service: "dev-yah".to_string(),
163            route: "/releases".to_string(),
164        };
165        let err = dispatch_on_change(&on_change, root, "prod")
166            .await
167            .unwrap_err();
168        let msg = format!("{err:#}");
169        assert!(msg.contains("no mirror"), "got: {msg}");
170    }
171
172    /// R707-F4: a `reload` needs no service declared in `.yah/services/` and no
173    /// mirror for `env` — it dispatches nothing. It must not be routed through
174    /// the mesofact path and fail on a missing component that was never
175    /// relevant.
176    #[tokio::test]
177    async fn reload_dispatches_nothing_and_needs_no_service_config() {
178        let tmp = TempDir::new().unwrap();
179        let on_change = OnChangeConfig::Reload {
180            service: "yah-cloud-admin".to_string(),
181        };
182        dispatch_on_change(&on_change, tmp.path(), "prod")
183            .await
184            .expect("a reload must succeed against an empty workspace");
185    }
186}