Skip to main content

aion_server/worker/auto_provision/
documents.rs

1//! Where the server keeps the documents its OWN managed workers read.
2//!
3//! # Why a file at all
4//!
5//! `aion worker agent` serves the queue an `.awl` document declares, and the
6//! whole launch — harness kind, concurrency, reconnect budget, the agent's own
7//! command and environment — is IN that document. A managed-worker record
8//! replays its argv verbatim, so the argv has to name a path that still holds
9//! the same bytes on the next restart, a week later, across a server upgrade.
10//! The server has those bytes: they ride in the deployed archive's `awl/`
11//! provenance tree. So it writes them down.
12//!
13//! # Content-addressed, and addressed by the DOCUMENT
14//!
15//! The directory name carries a digest of the document text, NOT the package's
16//! content hash. Those are different facts and the difference is load-bearing:
17//! adding or editing a `harness` section changes no lowering (the MIR ratchet
18//! pins that), so two packages that differ only in their harness section share
19//! one content hash. Keying the snapshot on the package hash would therefore
20//! hand a re-deploy the OLD document, and the re-mint that is supposed to move
21//! the worker onto the new launch would be a no-op.
22//!
23//! Content-addressing is also what makes a re-mint honest in the other
24//! direction: a redeploy of unchanged bytes resolves to the same path, so the
25//! record's argv is unchanged and the running worker is left alone.
26//!
27//! # Not the studio's tree
28//!
29//! `<AION_HOME>/authoring` is the AWL studio's root: an operator-writable
30//! document tree served over HTTP. These snapshots are neither — they are
31//! immutable server-owned state that a running process reads by path, and an
32//! edit through the studio would silently change what a worker serves on its
33//! next restart while the record still names the old digest. They live under
34//! `<AION_HOME>/workers/documents/` instead.
35
36use std::collections::BTreeSet;
37use std::path::{Component, Path, PathBuf};
38
39use aion_package::AwlSource;
40use sha2::{Digest, Sha256};
41
42use crate::filesystem::ConfinedDir;
43use crate::worker::lowercase_hex;
44
45/// The home-relative root every snapshot is written under.
46const DOCUMENTS_ROOT: &str = "workers/documents";
47
48/// Why a deployed document could not be staged for a managed worker.
49#[derive(Debug, thiserror::Error)]
50pub enum DocumentStoreError {
51    /// The Aion home could not be resolved, so there is nowhere server-owned
52    /// to put the document.
53    #[error(
54        "the Aion home could not be resolved, so the deployed document has nowhere to live: {message}"
55    )]
56    HomeUnresolved {
57        /// The resolution failure, verbatim.
58        message: String,
59    },
60    /// A name from the archive is not a single ordinary path component.
61    #[error(
62        "the deployed package names `{name}` inside its archived AWL tree, which is not a \
63         relative path of ordinary components; nothing was written"
64    )]
65    UnsafeName {
66        /// The rejected name, verbatim.
67        name: String,
68    },
69    /// The filesystem refused.
70    #[error("the deployed document could not be written under `{path}`: {message}")]
71    Write {
72        /// The path being written, for the operator to inspect.
73        path: String,
74        /// The operating system's own diagnosis.
75        message: String,
76    },
77}
78
79/// One deployed document, staged on disk with the schema files it imports.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct StagedDocument {
82    /// Absolute path of the document itself — the path a record's argv names.
83    pub path: PathBuf,
84    /// Lowercase hexadecimal SHA-256 of the document text. Two deploys of the
85    /// same bytes produce the same digest and therefore the same argv.
86    pub digest: String,
87}
88
89/// Write `source`'s document and every schema it imports under the server's own
90/// managed-worker document root, and return the document's absolute path.
91///
92/// Idempotent by construction: the directory is named by the document's digest,
93/// so re-staging identical bytes rewrites identical bytes at the same path.
94///
95/// # Errors
96///
97/// Returns [`DocumentStoreError::HomeUnresolved`] when the Aion home cannot be
98/// resolved, [`DocumentStoreError::UnsafeName`] for an archive entry whose name
99/// is not a relative path of ordinary components, and
100/// [`DocumentStoreError::Write`] when the filesystem refuses.
101pub fn stage(
102    root: &Path,
103    source: &AwlSource,
104    workflow_type: &str,
105) -> Result<StagedDocument, DocumentStoreError> {
106    let digest = lowercase_hex(&Sha256::digest(source.document().as_bytes()));
107    let version = version_directory(workflow_type, &digest);
108    let absolute = root.join(&version);
109    // ATOMIC AS A DIRECTORY, not merely file by file. Each write below is
110    // already atomic on its own, but a crash between the document and the last
111    // schema would leave a tree that LOOKS complete — the directory name is a
112    // digest, so nothing downstream re-checks it — and the next boot's
113    // reconcile would hand a worker a document whose `schema(…)` import is
114    // missing, crash-looping it. So the tree is built beside its final name and
115    // renamed into place, which is one atomic step.
116    let staging = root.join(format!("{version}.partial"));
117    remove_tree(&staging)?;
118    // Every missing component is created owner-only by the capability itself,
119    // and every file it writes is owner-only too, so there is no separate
120    // hardening pass to forget: a document that decides how an agent is
121    // launched is never world-readable.
122    let dir =
123        ConfinedDir::open_or_create(&staging).map_err(|error| write_failure(&staging, &error))?;
124
125    let document_name = safe_component(source.document_name())?;
126    dir.atomic_write(&document_name, source.document().as_bytes())
127        .map_err(|error| write_failure(&staging.join(&document_name), &error))?;
128
129    for (schema_path, bytes) in source.schemas() {
130        let relative = safe_relative(schema_path)?;
131        if let Some(parent) = relative
132            .parent()
133            .filter(|parent| !parent.as_os_str().is_empty())
134        {
135            dir.create_dir_all(parent)
136                .map_err(|error| write_failure(&staging.join(parent), &error))?;
137        }
138        dir.atomic_write(&relative, bytes)
139            .map_err(|error| write_failure(&staging.join(&relative), &error))?;
140    }
141    drop(dir);
142
143    // The rename cannot land on top of an existing directory, and an existing
144    // one is already the SAME bytes (the name is their digest), so the staged
145    // copy is simply discarded rather than swapped in.
146    if absolute.exists() {
147        remove_tree(&staging)?;
148    } else {
149        std::fs::rename(&staging, &absolute).map_err(|error| write_failure(&absolute, &error))?;
150    }
151
152    Ok(StagedDocument {
153        path: absolute.join(document_name),
154        digest,
155    })
156}
157
158/// The server-owned root every snapshot lives under, resolved from the Aion
159/// home.
160///
161/// # Errors
162///
163/// Returns [`DocumentStoreError::HomeUnresolved`] when the Aion home cannot be
164/// resolved. Nothing is defaulted: a snapshot written somewhere nobody chose is
165/// a path a record would then replay forever.
166pub fn root() -> Result<PathBuf, DocumentStoreError> {
167    crate::config::aion_home()
168        .map(|home| home.path.join(DOCUMENTS_ROOT))
169        .map_err(|error| DocumentStoreError::HomeUnresolved {
170            message: error.to_string(),
171        })
172}
173
174/// Remove every snapshot directory under `root` that no live record names.
175///
176/// A redeploy of a changed document writes a NEW digest directory and the old
177/// one is not automatically anyone's to delete — a record minted against it may
178/// still be replaying that argv until its own re-mint converges. But once no
179/// record's argv names it, nothing will ever read it again, and leaving it is
180/// an unbounded leak in the server's own state directory: a CI loop redeploying
181/// a document a hundred times a day accumulates a hundred trees a day.
182///
183/// `live` is every path a current worker-deployment argv names. A path this
184/// cannot classify is KEPT: deleting a snapshot on an unreadable answer is how
185/// a running worker loses its document.
186///
187/// # Errors
188///
189/// Returns [`DocumentStoreError::Write`] when the root cannot be listed or a
190/// directory cannot be removed.
191pub fn prune(root: &Path, live: &BTreeSet<PathBuf>) -> Result<Vec<PathBuf>, DocumentStoreError> {
192    if !root.exists() {
193        return Ok(Vec::new());
194    }
195    let entries = std::fs::read_dir(root).map_err(|error| write_failure(root, &error))?;
196    let mut removed = Vec::new();
197    for entry in entries {
198        let entry = entry.map_err(|error| write_failure(root, &error))?;
199        let path = entry.path();
200        if !path.is_dir() {
201            continue;
202        }
203        // A snapshot is named by any live path whose ancestor it is: the argv
204        // names the DOCUMENT inside the directory, not the directory.
205        if live.iter().any(|document| document.starts_with(&path)) {
206            continue;
207        }
208        remove_tree(&path)?;
209        removed.push(path);
210    }
211    Ok(removed)
212}
213
214/// Remove one directory tree, treating "it was not there" as success.
215fn remove_tree(path: &Path) -> Result<(), DocumentStoreError> {
216    match std::fs::remove_dir_all(path) {
217        Ok(()) => Ok(()),
218        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
219        Err(error) => Err(write_failure(path, &error)),
220    }
221}
222
223/// The per-version directory name: the workflow type, then its document digest.
224///
225/// The type is sanitised rather than trusted. It is an AWL identifier by the
226/// time a package exists, but this reads an archive a stranger may have built,
227/// and a directory name is not the place to find out.
228fn version_directory(workflow_type: &str, digest: &str) -> String {
229    format!("{}@{digest}", sanitise_workflow_type(workflow_type))
230}
231
232/// The workflow type as it is spelled in a staged directory's name.
233///
234/// One spelling, used by the deploy that names the directory and by every
235/// reader that has to compare a record's staged type against a package's
236/// workflow type — a type that contains a character the directory cannot is
237/// compared in the form the directory carries, never the raw one.
238pub(super) fn sanitise_workflow_type(workflow_type: &str) -> String {
239    let sanitised: String = workflow_type
240        .chars()
241        .map(|character| {
242            if character.is_ascii_alphanumeric() || character == '_' || character == '-' {
243                character
244            } else {
245                '_'
246            }
247        })
248        .collect();
249    if sanitised.is_empty() {
250        "workflow".to_owned()
251    } else {
252        sanitised
253    }
254}
255
256/// One archive entry name reduced to a single ordinary path component.
257fn safe_component(name: &str) -> Result<PathBuf, DocumentStoreError> {
258    let path = Path::new(name);
259    let mut components = path.components();
260    match (components.next(), components.next()) {
261        (Some(Component::Normal(single)), None) => Ok(PathBuf::from(single)),
262        _ => Err(DocumentStoreError::UnsafeName {
263            name: name.to_owned(),
264        }),
265    }
266}
267
268/// One archive entry name reduced to a relative path of ordinary components.
269///
270/// `ConfinedDir` is capability-rooted and would refuse an escape anyway; this
271/// refuses it BY NAME first, so the log says which entry was rejected rather
272/// than reporting a bare permission error against a path nobody wrote.
273fn safe_relative(name: &str) -> Result<PathBuf, DocumentStoreError> {
274    let path = Path::new(name);
275    let mut safe = PathBuf::new();
276    let mut any = false;
277    for component in path.components() {
278        match component {
279            Component::Normal(part) => {
280                safe.push(part);
281                any = true;
282            }
283            _ => {
284                return Err(DocumentStoreError::UnsafeName {
285                    name: name.to_owned(),
286                });
287            }
288        }
289    }
290    if any {
291        Ok(safe)
292    } else {
293        Err(DocumentStoreError::UnsafeName {
294            name: name.to_owned(),
295        })
296    }
297}
298
299fn write_failure(path: &Path, error: &std::io::Error) -> DocumentStoreError {
300    DocumentStoreError::Write {
301        path: path.display().to_string(),
302        message: error.to_string(),
303    }
304}
305
306#[cfg(test)]
307#[path = "documents_tests.rs"]
308mod tests;