Skip to main content

release_kit/plan/
store.rs

1//! The plan store: one directory per plan under the state root, holding
2//! the plan, the request that computed it, and every byte it names.
3//!
4//! A stored plan is the thing that was reviewed, and an apply executes
5//! exactly its operations with exactly its bytes. Plans carry bytes from
6//! the target, some of which are not public, so the store is owner-only,
7//! the JSON view carries digests and bounded text rather than blobs, and
8//! a plan is ephemeral: never committed and never posted to a forge.
9//! Retention is bounded the way the run journal is: the newest
10//! [`PLANS_KEPT`] plans stay, pruned after every persist.
11
12use std::collections::BTreeMap;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use crate::applog;
17use crate::atomic;
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::digest::Digest;
20use crate::error::RkError;
21
22use super::{Plan, PlanRequest, Planned};
23
24/// How many plans the store keeps; a persist past the cap prunes the
25/// oldest first.
26pub const PLANS_KEPT: usize = 20;
27
28/// The plan document inside a plan's directory.
29pub const PLAN_FILE: &str = "plan.json";
30/// The request that computed it, for the revalidation.
31pub const REQUEST_FILE: &str = "request.json";
32/// The blobs, one file per digest.
33pub const BLOBS_DIR: &str = "blobs";
34
35/// A plan read back from the store.
36#[derive(Debug)]
37pub struct Stored {
38    /// The plan as persisted.
39    pub plan: Plan,
40    /// The request that computed it.
41    pub request: PlanRequest,
42    /// Every blob the plan names, by digest.
43    pub blobs: BTreeMap<Digest, Vec<u8>>,
44    /// The plan's directory.
45    pub dir: PathBuf,
46}
47
48/// The store root: `<state root>/plans`.
49#[must_use]
50pub fn plans_root() -> Option<PathBuf> {
51    applog::state_root().map(|root| root.join("plans"))
52}
53
54/// Persist one computed plan with its request and its blobs, then prune
55/// the store to its cap. The directory is created owner-only.
56///
57/// # Errors
58///
59/// Returns a refusal with [`Reason::JournalUnavailable`] where no state
60/// root resolves, and [`RkError::Io`] for a write that fails.
61pub fn persist(planned: &Planned, request: &PlanRequest) -> Result<PathBuf, RkError> {
62    let root = plans_root().ok_or_else(no_root)?;
63    fs::create_dir_all(&root)?;
64    restrict_dir(&root);
65    let dir = root.join(&planned.plan.identity.plan_id);
66    let staging = root.join(format!(".{}.staging", planned.plan.identity.plan_id));
67    let _ = fs::remove_dir_all(&staging);
68    fs::create_dir(&staging)?;
69    restrict_dir(&staging);
70    let blobs = staging.join(BLOBS_DIR);
71    fs::create_dir(&blobs)?;
72    restrict_dir(&blobs);
73    for (digest, bytes) in &planned.blobs {
74        let path = blobs.join(digest.to_string());
75        atomic::write(&path, bytes)?;
76        restrict_file(&path);
77    }
78    let request_json = serde_json::to_vec_pretty(request).map_err(anyhow::Error::from)?;
79    atomic::write(&staging.join(REQUEST_FILE), &request_json)?;
80    restrict_file(&staging.join(REQUEST_FILE));
81    let plan_json = serde_json::to_vec_pretty(&planned.plan).map_err(anyhow::Error::from)?;
82    atomic::write(&staging.join(PLAN_FILE), &plan_json)?;
83    restrict_file(&staging.join(PLAN_FILE));
84    // The same id names the same fingerprint at the same instant, so a
85    // directory already there holds this very plan: a second writer
86    // keeps it and drops its own staging rather than racing the first.
87    if let Err(error) = fs::rename(&staging, &dir) {
88        let _ = fs::remove_dir_all(&staging);
89        if !dir.join(PLAN_FILE).is_file() {
90            return Err(RkError::Io(error));
91        }
92    }
93    let _ = prune_to(PLANS_KEPT);
94    Ok(dir)
95}
96
97/// Read one plan back by id.
98///
99/// # Errors
100///
101/// Returns [`RkError::Missing`] naming the id and the retention rule
102/// where no plan directory holds it, a refusal with
103/// [`Reason::UnsupportedSchema`] where the stored document is not one
104/// this engine reads, and [`RkError::Io`] for a read that fails.
105pub fn load(id: &str) -> Result<Stored, RkError> {
106    // An id is one directory name under the store, never a path.
107    if id.contains(['/', '\\']) || id == ".." || id == "." || id.is_empty() {
108        return Err(unknown(id));
109    }
110    let root = plans_root().ok_or_else(no_root)?;
111    let dir = root.join(id);
112    let plan_bytes = match fs::read(dir.join(PLAN_FILE)) {
113        Ok(bytes) => bytes,
114        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Err(unknown(id)),
115        Err(error) => return Err(RkError::Io(error)),
116    };
117    let plan: Plan = serde_json::from_slice(&plan_bytes).map_err(|error| {
118        RkError::refusal(
119            Diagnostic::new(
120                Reason::UnsupportedSchema,
121                format!("plan {id} does not read as {}: {error}", super::PLAN_SCHEMA),
122            )
123            .expected("a plan this engine stored")
124            .action("rk reconcile plan computes a fresh one"),
125        )
126    })?;
127    if plan.schema != super::PLAN_SCHEMA {
128        return Err(RkError::refusal(
129            Diagnostic::new(
130                Reason::UnsupportedSchema,
131                format!(
132                    "plan {id} declares {} and this engine reads {}",
133                    plan.schema,
134                    super::PLAN_SCHEMA
135                ),
136            )
137            .expected("a plan computed by this engine")
138            .action("rk reconcile plan computes a fresh one"),
139        ));
140    }
141    let request: PlanRequest =
142        serde_json::from_slice(&fs::read(dir.join(REQUEST_FILE))?).map_err(anyhow::Error::from)?;
143    let mut blobs = BTreeMap::new();
144    let blobs_dir = dir.join(BLOBS_DIR);
145    if blobs_dir.is_dir() {
146        for entry in fs::read_dir(&blobs_dir)? {
147            let entry = entry?;
148            let name = entry.file_name().to_string_lossy().into_owned();
149            if let Some(digest) = Digest::parse(&name) {
150                blobs.insert(digest, fs::read(entry.path())?);
151            }
152        }
153    }
154    Ok(Stored {
155        plan,
156        request,
157        blobs,
158        dir,
159    })
160}
161
162/// Every stored plan, oldest first, as `(created_at, id)`.
163#[must_use]
164pub fn list() -> Vec<(String, String)> {
165    let Some(root) = plans_root() else {
166        return Vec::new();
167    };
168    let Ok(entries) = fs::read_dir(root) else {
169        return Vec::new();
170    };
171    let mut plans: Vec<(String, String)> = entries
172        .filter_map(Result::ok)
173        .filter(|entry| entry.path().is_dir())
174        .filter_map(|entry| {
175            let id = entry.file_name().to_string_lossy().into_owned();
176            if id.starts_with('.') {
177                return None;
178            }
179            let bytes = fs::read(entry.path().join(PLAN_FILE)).ok()?;
180            let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
181            let created = value["identity"]["created_at"].as_str()?.to_owned();
182            Some((created, id))
183        })
184        .collect();
185    plans.sort();
186    plans
187}
188
189/// Remove the oldest plans past `keep`; the count removed.
190#[must_use]
191pub fn prune_to(keep: usize) -> usize {
192    let Some(root) = plans_root() else { return 0 };
193    let plans = list();
194    let excess = plans.len().saturating_sub(keep);
195    let mut removed = 0;
196    for (_, id) in plans.into_iter().take(excess) {
197        if fs::remove_dir_all(root.join(id)).is_ok() {
198            removed += 1;
199        }
200    }
201    removed
202}
203
204fn no_root() -> RkError {
205    RkError::refusal(
206        Diagnostic::new(
207            Reason::JournalUnavailable,
208            "neither XDG_STATE_HOME nor HOME is set; the plan store has no root",
209        )
210        .expected("a state root for the plan store"),
211    )
212}
213
214fn unknown(id: &str) -> RkError {
215    RkError::missing(
216        Diagnostic::new(
217            Reason::Usage,
218            format!(
219                "no stored plan {id}: the store keeps the newest {PLANS_KEPT} plans and prunes the rest after every persist"
220            ),
221        )
222        .expected("the id rk reconcile plan printed, still within the retention window")
223        .action("rk reconcile plan computes and stores a fresh plan"),
224    )
225}
226
227/// 0700 on a store directory.
228fn restrict_dir(dir: &Path) {
229    #[cfg(unix)]
230    {
231        use std::os::unix::fs::PermissionsExt as _;
232        let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
233    }
234    #[cfg(not(unix))]
235    let _ = dir;
236}
237
238/// 0600 on a stored file: data, not an executable.
239fn restrict_file(path: &Path) {
240    #[cfg(unix)]
241    {
242        use std::os::unix::fs::PermissionsExt as _;
243        let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
244    }
245    #[cfg(not(unix))]
246    let _ = path;
247}