Page Menu
Home
DevCentral
Search
Configure Global Search
Log In
Files
F47404735
D4201.id11019.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
6 KB
Referenced Files
None
Subscribers
None
D4201.id11019.diff
View Options
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -91,6 +91,10 @@
- init: called by `alkane init`
- update: called by `alkane update`
+If your update procedure is idempotent and can also initialize the site,
+you can provide only the update script. Alkane will use it when an init
+script isn't provided.
+
Several environment variables are available to those scripts:
| Variable | Description |
diff --git a/src/runner/store.rs b/src/runner/store.rs
--- a/src/runner/store.rs
+++ b/src/runner/store.rs
@@ -8,7 +8,7 @@
use std::collections::HashMap;
use std::path::Path;
-use log::error;
+use log::{error, info};
use crate::config::AlkaneConfig;
use crate::runner::run;
@@ -47,7 +47,7 @@
}
pub async fn run_recipe(&self, site: &Site, action: &str) -> RecipeStatus {
- let Some(command) = self.get_recipe_path(&site.name, action) else {
+ let Some(command) = self.resolve_recipe_path(&site.name, action) else {
error!(
"Refusing to run a recipe for invalid site name {:?}",
site.name
@@ -59,6 +59,34 @@
run(command, Vec::new(), environment).await
}
+ /// Resolve the recipe to run for a site and action.
+ ///
+ /// If the update recipe can also initialize a fresh site, a site can omit
+ /// its init recipe. In that case, init resolves to the update recipe.
+ fn resolve_recipe_path(&self, site_name: &str, action: &str) -> Option<String> {
+ let path = self.get_recipe_path(site_name, action)?;
+
+ let init_is_absent = action == "init"
+ && matches!(
+ std::fs::symlink_metadata(&path),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound
+ );
+
+ if init_is_absent {
+ let fallback = self.get_recipe_path(site_name, "update")?;
+
+ if matches!(Path::new(&fallback).try_exists(), Ok(true)) {
+ info!(
+ "No init recipe for {}, falling back to the update recipe",
+ site_name
+ );
+ return Some(fallback);
+ }
+ }
+
+ Some(path)
+ }
+
fn get_environment(&self, site: &Site) -> HashMap<String, String> {
let mut map = HashMap::new();
@@ -82,7 +110,9 @@
#[cfg(test)]
mod tests {
+ use std::fs;
use std::path::MAIN_SEPARATOR_STR;
+ use std::path::PathBuf;
use super::*;
@@ -156,4 +186,126 @@
assert!(environment.contains_key("ALKANE_SITE_CONTEXT"));
assert_eq!("CH3-CH3", environment["ALKANE_SITE_CONTEXT"])
}
+
+ // -------------------------------------------------------------
+ // Recipe resolution helpers
+ // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+ struct TempStore {
+ root: PathBuf,
+ store: RecipesStore,
+ }
+
+ impl TempStore {
+ fn new(name: &str) -> Self {
+ let root = std::env::temp_dir().join(format!(
+ "alkane-test-{}-{}",
+ std::process::id(),
+ name
+ ));
+
+ let _ = fs::remove_dir_all(&root);
+ fs::create_dir_all(&root).expect("Can't create temporary recipe store");
+
+ let store = RecipesStore::new(root.to_str().expect("Non-UTF8 temp path"));
+
+ Self { root, store }
+ }
+ }
+
+ impl Drop for TempStore {
+ fn drop(&mut self) {
+ let _ = fs::remove_dir_all(&self.root);
+ }
+ }
+
+ /// Create an empty recipe file for a site and action
+ fn add_recipe(root: &Path, site_name: &str, action: &str) {
+ let site_dir = root.join(site_name);
+ fs::create_dir_all(&site_dir).expect("Can't create site recipe directory");
+ fs::write(site_dir.join(action), "#!/bin/sh\n").expect("Can't write recipe");
+ }
+
+ // -------------------------------------------------------------
+ // Recipe resolution tests
+ // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+ #[test]
+ pub fn test_resolve_recipe_path_prefers_init() {
+ let temp = TempStore::new("resolve-prefer-init");
+ add_recipe(&temp.root, "foo.acme.tld", "init");
+ add_recipe(&temp.root, "foo.acme.tld", "update");
+
+ let expected = temp.root.join("foo.acme.tld").join("init");
+
+ assert_eq!(
+ Some(expected.to_str().unwrap().to_string()),
+ temp.store.resolve_recipe_path("foo.acme.tld", "init")
+ );
+ }
+
+ #[test]
+ pub fn test_resolve_recipe_path_falls_back_to_update() {
+ let temp = TempStore::new("resolve-fallback");
+ add_recipe(&temp.root, "foo.acme.tld", "update");
+
+ let expected = temp.root.join("foo.acme.tld").join("update");
+
+ assert_eq!(
+ Some(expected.to_str().unwrap().to_string()),
+ temp.store.resolve_recipe_path("foo.acme.tld", "init")
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ pub fn test_resolve_recipe_path_keeps_dangling_init_symlink() {
+ let temp = TempStore::new("resolve-dangling-init");
+ add_recipe(&temp.root, "foo.acme.tld", "update");
+
+ let init = temp.root.join("foo.acme.tld").join("init");
+ std::os::unix::fs::symlink("missing-init-target", &init)
+ .expect("Can't create dangling init symlink");
+
+ assert_eq!(
+ Some(init.to_str().unwrap().to_string()),
+ temp.store.resolve_recipe_path("foo.acme.tld", "init")
+ );
+ }
+
+ #[test]
+ pub fn test_resolve_recipe_path_update_not_affected_by_fallback() {
+ let temp = TempStore::new("resolve-update");
+ add_recipe(&temp.root, "foo.acme.tld", "update");
+
+ let expected = temp.root.join("foo.acme.tld").join("update");
+
+ assert_eq!(
+ Some(expected.to_str().unwrap().to_string()),
+ temp.store.resolve_recipe_path("foo.acme.tld", "update")
+ );
+ }
+
+ #[test]
+ pub fn test_resolve_recipe_path_init_without_any_recipe() {
+ let temp = TempStore::new("resolve-no-recipe");
+ fs::create_dir_all(temp.root.join("foo.acme.tld")).expect("Can't create site recipe directory");
+
+ // No fallback available: the init path is returned as-is,
+ // and run() will report the missing recipe.
+ let expected = temp.root.join("foo.acme.tld").join("init");
+
+ assert_eq!(
+ Some(expected.to_str().unwrap().to_string()),
+ temp.store.resolve_recipe_path("foo.acme.tld", "init")
+ );
+ }
+
+ #[test]
+ pub fn test_resolve_recipe_path_rejects_traversal() {
+ let temp = TempStore::new("resolve-traversal");
+
+ assert_eq!(None, temp.store.resolve_recipe_path("../foo.acme.tld", "init"));
+ assert_eq!(None, temp.store.resolve_recipe_path("../foo.acme.tld", "update"));
+ }
}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Mon, Aug 31, 23:09 (12 h, 5 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
4050700
Default Alt Text
D4201.id11019.diff (6 KB)
Attached To
Mode
D4201: Allow update script to be used for init too
Attached
Detach File
Event Timeline
Log In to Comment