Skip to main content

aion_server/update_check/
install.rs

1//! Boot-time installation of the embedded update-check document into the
2//! engine catalog.
3//!
4//! # The same rule as the assistant, for the same reason
5//!
6//! The embedded document is loaded (and therefore routed) ONLY when the
7//! engine holds no version of the update-check workflow type at all.
8//! [`aion::Engine::load_package`] re-points routing for a package's workflow
9//! type on every load — correct for an operator deploy, wrong for a restart:
10//! a server that loaded the embedded document unconditionally at boot would
11//! undo an operator's deliberate deploy every time the process restarted. See
12//! [`crate::assistant::install`], whose policy this mirrors verbatim.
13//!
14//! # Installing is not checking
15//!
16//! This makes the check STARTABLE, nothing more. No workflow is started, no
17//! network is touched, nothing is fetched: the check is manual-only, and every
18//! run of it is an explicit operator act. [`install_tests`] pins that a boot
19//! install leaves zero `update_check` executions behind.
20
21use aion::Engine;
22
23use super::document::{EmbeddedUpdateCheck, embedded_update_check};
24
25/// What the boot install did, and to what.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum UpdateCheckInstall {
28    /// The engine held no version of the update-check type, so the embedded
29    /// document was loaded and routed.
30    Installed {
31        /// The workflow type now routed.
32        workflow_type: String,
33        /// The embedded content hash that took the route.
34        content_hash: String,
35    },
36    /// A version was already resident AND routed, and it is the embedded one.
37    AlreadyCurrent {
38        /// The workflow type.
39        workflow_type: String,
40        /// The routed content hash, equal to the embedded one.
41        content_hash: String,
42    },
43    /// Versions are resident but the embedded one does not hold the route.
44    /// The cut is the operator's to make.
45    Deferred {
46        /// The workflow type.
47        workflow_type: String,
48        /// The embedded document's content hash.
49        embedded_hash: String,
50        /// The routed hash, or `None` when no resident version holds the
51        /// route.
52        routed_hash: Option<String>,
53    },
54    /// The install could not be attempted or did not complete. The server
55    /// keeps serving; the update check is simply not installed by this boot.
56    Failed {
57        /// What refused, verbatim.
58        reason: String,
59    },
60}
61
62impl UpdateCheckInstall {
63    /// A stable label for logs and metrics.
64    #[must_use]
65    pub const fn outcome(&self) -> &'static str {
66        match self {
67            Self::Installed { .. } => "installed",
68            Self::AlreadyCurrent { .. } => "already_current",
69            Self::Deferred { .. } => "deferred",
70            Self::Failed { .. } => "failed",
71        }
72    }
73}
74
75/// Installs the embedded update-check document into `engine` under the rule
76/// above, logging exactly what happened.
77///
78/// Never returns an error: a server whose update check could not be installed
79/// is a server that cannot check for updates, not a server that must refuse
80/// to boot.
81pub async fn install_embedded_update_check(engine: &Engine) -> UpdateCheckInstall {
82    let embedded = match embedded_update_check() {
83        Ok(embedded) => embedded,
84        Err(error) => {
85            let reason = error.to_string();
86            tracing::error!(
87                operation = "update_check.install",
88                outcome = "failed",
89                %reason,
90                "the embedded update-check document could not be prepared; this server cannot \
91                 run the built-in update check"
92            );
93            return UpdateCheckInstall::Failed { reason };
94        }
95    };
96    let outcome = install_verified(engine, embedded).await;
97    log_outcome(&outcome);
98    outcome
99}
100
101/// Installs the embedded update-check document into the engine `state` is
102/// serving — the server's own boot path. An engine that cannot be resolved is
103/// reported as a failed install rather than a failed boot.
104pub async fn install_embedded_update_check_for_server(
105    state: &crate::ServerState,
106) -> UpdateCheckInstall {
107    match state.engine() {
108        Ok(engine) => install_embedded_update_check(engine.as_ref()).await,
109        Err(error) => {
110            let reason = format!("the engine is not available: {error}");
111            let outcome = UpdateCheckInstall::Failed { reason };
112            log_outcome(&outcome);
113            outcome
114        }
115    }
116}
117
118/// The install decision itself, over an already-prepared embedded document.
119async fn install_verified(engine: &Engine, embedded: &EmbeddedUpdateCheck) -> UpdateCheckInstall {
120    let workflow_type = embedded.workflow_type().to_owned();
121    let embedded_hash = embedded.content_hash().to_string();
122
123    let versions = match engine.list_workflow_versions() {
124        Ok(versions) => versions,
125        Err(error) => {
126            return UpdateCheckInstall::Failed {
127                reason: format!("the engine catalog could not be read: {error}"),
128            };
129        }
130    };
131    let resident: Vec<_> = versions
132        .into_iter()
133        .filter(|version| version.workflow_type == workflow_type)
134        .collect();
135
136    if resident.is_empty() {
137        return match engine.load_package(embedded.package().clone()).await {
138            Ok(_) => UpdateCheckInstall::Installed {
139                workflow_type,
140                content_hash: embedded_hash,
141            },
142            Err(error) => UpdateCheckInstall::Failed {
143                reason: format!(
144                    "the embedded update-check package `{embedded_hash}` did not load: {error}"
145                ),
146            },
147        };
148    }
149
150    let routed_hash = resident
151        .iter()
152        .find(|version| version.route_active)
153        .map(|version| version.content_hash.to_string());
154    if routed_hash.as_deref() == Some(embedded_hash.as_str()) {
155        return UpdateCheckInstall::AlreadyCurrent {
156            workflow_type,
157            content_hash: embedded_hash,
158        };
159    }
160    UpdateCheckInstall::Deferred {
161        workflow_type,
162        embedded_hash,
163        routed_hash,
164    }
165}
166
167/// One structured line per boot stating what the install did, and — when it
168/// stood down — what the operator would do about it.
169fn log_outcome(outcome: &UpdateCheckInstall) {
170    match outcome {
171        UpdateCheckInstall::Installed {
172            workflow_type,
173            content_hash,
174        } => tracing::info!(
175            operation = "update_check.install",
176            outcome = outcome.outcome(),
177            %workflow_type,
178            %content_hash,
179            "the built-in update check was installed and routed on a catalog that held no \
180             version of it; nothing was started — every check is an explicit operator act"
181        ),
182        UpdateCheckInstall::AlreadyCurrent {
183            workflow_type,
184            content_hash,
185        } => tracing::info!(
186            operation = "update_check.install",
187            outcome = outcome.outcome(),
188            %workflow_type,
189            %content_hash,
190            "the built-in update check is already the routed version"
191        ),
192        UpdateCheckInstall::Deferred {
193            workflow_type,
194            embedded_hash,
195            routed_hash,
196        } => tracing::warn!(
197            operation = "update_check.install",
198            outcome = outcome.outcome(),
199            %workflow_type,
200            %embedded_hash,
201            routed_hash = routed_hash.as_deref().unwrap_or("none"),
202            "a version of the update-check workflow type is already resident on this catalog, \
203             and routing was NOT changed — a restart must never move a route an operator \
204             chose. To return to the embedded document deliberately, retire the resident \
205             versions (`aion unload update_check <hash>`) and restart"
206        ),
207        UpdateCheckInstall::Failed { reason } => tracing::error!(
208            operation = "update_check.install",
209            outcome = outcome.outcome(),
210            %reason,
211            "the built-in update check was not installed"
212        ),
213    }
214}
215
216#[cfg(test)]
217#[path = "install_tests.rs"]
218mod install_tests;