Skip to main content

ninja/
shuriken.rs

1use crate::common::types::ShurikenState;
2use crate::manager::ShurikenManager;
3use crate::utils::get_port_owner;
4use crate::{common::types::FieldValue, scripting::NinjaEngine, scripting::templater::Templater};
5use anyhow::Result;
6use log::info;
7use serde::{Deserialize, Serialize};
8use serde_json::{Value as JsonValue, json};
9use std::sync::Arc;
10use std::{
11    collections::HashMap,
12    path::{Path, PathBuf},
13};
14use tokio::{fs, sync::Mutex};
15
16/// Represents a tool script associated with a Shuriken.
17///
18/// Tools are executable scripts that can be invoked to perform
19/// specific tasks related to the Shuriken.
20#[derive(Debug, Deserialize, Serialize, Clone)]
21pub struct Tool {
22    /// The name of the tool
23    pub name: String,
24    /// Path to the tool's script file
25    pub script: PathBuf,
26    /// Optional human-readable description
27    pub description: Option<String>,
28}
29
30/// Configuration settings for a Shuriken.
31///
32/// Specifies the path to configuration templates and runtime options.
33#[derive(Debug, Serialize, Deserialize, Clone)]
34pub struct ShurikenConfig {
35    /// Path to the configuration file template (relative to Shuriken directory)
36    #[serde(rename = "config-path")]
37    pub config_path: PathBuf,
38    /// Runtime configuration options applied during execution
39    pub options: Option<HashMap<String, FieldValue>>,
40}
41
42/// Metadata describing a Shuriken in shuriken.toml.
43///
44/// Contains identifying information, type, version, and optional startup details.
45#[derive(Debug, Serialize, Deserialize, Clone)]
46pub struct ShurikenMetadata {
47    /// Human-readable name of the Shuriken
48    pub name: String,
49    /// Unique identifier for the Shuriken
50    pub id: String,
51    /// Version string (e.g., "1.0.0")
52    pub version: String,
53    /// Optional list of TCP ports used by the service
54    pub ports: Option<Vec<u16>>,
55    /// Whether to verify ports are free before starting (default: false)
56    #[serde(rename = "check-ports")]
57    pub check_ports: Option<bool>,
58    /// Path to the main startup script
59    #[serde(rename = "script-path")]
60    pub script_path: Option<PathBuf>,
61    /// Type of Shuriken: "daemon", "binary", "library", etc.
62    #[serde(rename = "type")]
63    pub shuriken_type: String,
64}
65
66/// Logging configuration for a Shuriken.
67///
68/// Specifies where the Shuriken should write its log files.
69#[derive(Debug, Serialize, Deserialize, Clone)]
70pub struct LogsConfig {
71    /// Path where logs should be written
72    #[serde(rename = "log-path")]
73    pub log_path: PathBuf,
74}
75
76async fn atomic_write_json(path: &Path, value: &JsonValue) -> Result<(), String> {
77    use tokio::fs;
78
79    let tmp_path = path.with_extension("tmp");
80
81    let data = serde_json::to_vec(value).map_err(|e| e.to_string())?;
82    fs::write(&tmp_path, data)
83        .await
84        .map_err(|e| format!("Failed to write tmp lockfile: {e}"))?;
85
86    // Atomic-ish replace on most platforms
87    std::fs::rename(&tmp_path, path).map_err(|e| format!("Failed to replace lockfile: {e}"))?;
88
89    Ok(())
90}
91
92/// Represents the complete TOML structure of a shuriken.toml file.
93///
94/// This is the raw representation before being parsed into a `Shuriken` struct.
95#[derive(Debug, Serialize, Deserialize, Clone)]
96pub struct ShurikenToml {
97    /// Required: Shuriken metadata section
98    pub shuriken: ShurikenMetadata,
99    /// Optional: Configuration section
100    pub config: Option<ShurikenConfig>,
101    /// Optional: Logging configuration
102    pub logs: Option<LogsConfig>,
103    /// Optional: Available tools
104    pub tools: Option<Vec<Tool>>,
105}
106
107/// A loaded and managed Shuriken service instance.
108///
109/// Represents an installed Shuriken with metadata, configuration, logging, and runtime state.
110/// The `state` and `dirty` flags are not serialized.
111#[derive(Debug, Serialize, Deserialize, Clone)]
112pub struct Shuriken {
113    /// Shuriken metadata
114    #[serde(rename = "shuriken")]
115    pub metadata: ShurikenMetadata,
116    /// Configuration settings
117    pub config: Option<ShurikenConfig>,
118    /// Logging configuration
119    pub logs: Option<LogsConfig>,
120    /// Available tools
121    pub tools: Option<Vec<Tool>>,
122
123    /// Current runtime state (Running, Idle, or Error)
124    #[serde(skip)]
125    pub state: Arc<Mutex<ShurikenState>>,
126    /// Whether in-memory state differs from disk
127    #[serde(skip)]
128    pub dirty: Arc<Mutex<bool>>,
129}
130
131impl Shuriken {
132    /// Starts this Shuriken by executing its startup script.
133    ///
134    /// Performs port availability checks if configured, creates necessary directories,
135    /// compiles and executes the startup script, and writes a lock file.
136    /// Updates internal state to `Running` on success.
137    ///
138    /// # Arguments
139    /// - `engine`: Reference to the Lua scripting engine
140    /// - `shuriken_dir`: Directory containing the Shuriken's files
141    /// - `mgr`: Optional manager reference for script context
142    ///
143    /// # Returns
144    /// - `Ok(())` if startup succeeded
145    /// - `Err(msg)` if port check fails, script execution fails, or I/O fails
146    pub async fn start(
147        &self,
148        engine: &NinjaEngine,
149        shuriken_dir: &Path,
150        mgr: Option<ShurikenManager>,
151    ) -> Result<(), String> {
152        info!("Starting shuriken {}", self.metadata.name);
153
154        let lock_dir = shuriken_dir.join(".ninja");
155        tokio::fs::create_dir_all(&lock_dir)
156            .await
157            .map_err(|e| format!("Failed to create .ninja directory: {}", e))?;
158        let lock_path = lock_dir.join("shuriken.lck");
159
160        if self.metadata.check_ports.unwrap_or(false) {
161            info!("Checking ports for shuriken {}", self.metadata.name);
162            if let Some(ports) = &self.metadata.ports {
163                for port in ports {
164                    let port_owner = get_port_owner(*port);
165                    if let Some(owner) = port_owner {
166                        let name = owner.name.unwrap_or_else(|| "Unknown".into());
167                        return Err(format!(
168                            "Port {} is already in use by process {}. Please free the port and try again.",
169                            port, name
170                        ));
171                    }
172                    else {
173                        info!("Port {} is free", port);
174                    }
175                }
176            }
177        }
178
179        if self.metadata.shuriken_type == "daemon"
180            && let Some(script_path) = &self.metadata.script_path
181        {
182            let full_script_path = self.resolve_script_path(script_path, shuriken_dir);
183
184            let stem = full_script_path
185                .file_stem()
186                .ok_or_else(|| "Invalid script path".to_string())?
187                .to_string_lossy()
188                .to_string();
189            let compiled_path = lock_dir.join(format!("{stem}.ns"));
190
191            if let Some(mgr) = mgr {
192                engine
193                    .execute_function("start", &compiled_path, Some(shuriken_dir), Some(mgr))
194                    .await
195                    .map_err(|e| format!("Script start failed: {}", e))?;
196            }
197
198            let lockfile_data = json!({
199                "name": self.metadata.name,
200                "type": "Script",
201            });
202
203            atomic_write_json(&lock_path, &lockfile_data).await?;
204
205            let mut state = self.state.lock().await;
206            *state = ShurikenState::Running;
207        }
208        Ok(())
209    }
210
211    /// Removes the lock file for this Shuriken (without stopping it).
212    ///
213    /// Useful for recovering from crashes where the lock file wasn't cleaned up.
214    ///
215    /// # Arguments
216    /// - `root_path`: The root Ninja directory
217    ///
218    /// # Returns
219    /// - `Ok(())` if lock file removed or didn't exist
220    /// - `Err` if operation fails
221    pub async fn lockpick(&self, root_path: &Path) -> anyhow::Result<()> {
222        let root_path = root_path
223            .join("shurikens")
224            .join(&self.metadata.name.to_lowercase())
225            .join(".ninja");
226
227        if root_path.join("shuriken.lck").exists() {
228            fs::remove_file(root_path.join("shuriken.lck")).await?;
229        }
230
231        Ok(())
232    }
233
234    /// Configures this Shuriken by templating its configuration file.
235    ///
236    /// Uses the `Templater` to render configuration templates with provided field values,
237    /// then executes the `post_config` function if a script path is defined.
238    ///
239    /// # Arguments
240    /// - `root_path`: The root Ninja directory
241    /// - `engine`: Reference to the Lua scripting engine
242    /// - `mgr`: Optional manager reference for script context
243    ///
244    /// # Returns
245    /// - `Ok(())` if configuration succeeded
246    /// - `Err` if no config/script path exists or template/script execution fails
247    pub async fn configure(
248        &self,
249        root_path: &Path,
250        engine: &NinjaEngine,
251        mgr: Option<ShurikenManager>,
252    ) -> anyhow::Result<()> {
253        if let Some(ctx) = &self.config
254            && let Some(script_path) = &self.metadata.script_path
255        {
256            let shuriken_fields = ctx.options.clone();
257            let mut fields = HashMap::new();
258            if let Some(partial_fields) = shuriken_fields {
259                for (name, value) in partial_fields {
260                    fields.insert(name, value);
261                }
262            }
263
264            // Construct full path to the shuriken folder
265            let shuriken_path = root_path
266                .join("shurikens")
267                .join(&self.metadata.name.to_lowercase());
268
269            // Ensure the directory exists
270            fs::create_dir_all(&shuriken_path).await?;
271
272            // Initialize Templater with the fields and shuriken path
273            let templater = Templater::new(fields, shuriken_path.clone())?;
274
275            // Full path to write the generated config
276            let config_full_path = shuriken_path.join(&ctx.config_path);
277
278            // Ensure the parent directory of the config file exists
279            if let Some(parent) = config_full_path.parent() {
280                fs::create_dir_all(parent).await?;
281            }
282
283            templater
284                .generate_config(config_full_path)
285                .await
286                .map_err(|e| anyhow::Error::msg(e.to_string()))?;
287
288            engine
289                .execute_function("post_config", script_path, Some(root_path), mgr)
290                .await?;
291
292            Ok(())
293        } else {
294            Err(anyhow::Error::msg(
295                "Shuriken does not have a config or script path",
296            ))
297        }
298    }
299
300    /// Imports data for this Shuriken by executing its import script.
301    ///
302    /// Executes the `import` function if a script path is defined.
303    /// Used to prepare or initialize data before the Shuriken starts.
304    ///
305    /// # Arguments
306    /// - `engine`: Reference to the Lua scripting engine
307    /// - `shuriken_dir`: Directory containing the Shuriken's files
308    /// - `mgr`: Optional manager reference for script context
309    ///
310    /// # Returns
311    /// - `Ok(())` if import succeeded
312    /// - `Err(msg)` if no script path exists or script execution fails
313    pub async fn import(
314        &self,
315        engine: &NinjaEngine,
316        shuriken_dir: &Path,
317        mgr: Option<ShurikenManager>,
318    ) -> Result<(), String> {
319        info!(
320            "Shuriken {} is importing data! (if there is any)",
321            &self.metadata.name
322        );
323
324        if let Some(script_path) = &self.metadata.script_path {
325            let full_script_path = self.resolve_script_path(script_path, shuriken_dir);
326
327            let stem = full_script_path
328                .file_stem()
329                .ok_or_else(|| "Invalid script".to_string())?
330                .to_string_lossy()
331                .to_string();
332            let lock_dir = shuriken_dir.join(".ninja");
333            let compiled_path = lock_dir.join(format!("{stem}.ns"));
334
335            if let Some(mgr) = mgr {
336                engine
337                    .execute_function("import", &compiled_path, Some(shuriken_dir), Some(mgr))
338                    .await
339                    .map_err(|e| format!("Script import failed: {}", e))?;
340            }
341            Ok(())
342        } else {
343            Err("Shuriken does not have a script path".to_string())
344        }
345    }
346
347    /// Resolves a script path, handling both absolute and relative paths.
348    ///
349    /// If the path is absolute, returns it as-is.
350    /// Otherwise, joins it with the shuriken directory.
351    ///
352    /// # Arguments
353    /// - `script_path`: The script path to resolve
354    /// - `shuriken_dir`: The Shuriken's directory for relative resolution
355    ///
356    /// # Returns
357    /// The resolved absolute path
358    fn resolve_script_path(&self, script_path: &Path, shuriken_dir: &Path) -> PathBuf {
359        if script_path.is_absolute() {
360            script_path.to_path_buf()
361        } else {
362            shuriken_dir.join(script_path)
363        }
364    }
365
366    /// Stops this running Shuriken by executing its stop script.
367    ///
368    /// Calls the `stop` function if defined, removes the lock file,
369    /// and updates internal state to `Idle`.
370    ///
371    /// # Arguments
372    /// - `engine`: Reference to the Lua scripting engine
373    /// - `shuriken_dir`: Directory containing the Shuriken's files
374    /// - `mgr`: Optional manager reference for script context
375    ///
376    /// # Returns
377    /// - `Ok(())` if stop succeeded
378    /// - `Err(msg)` if script execution fails or lock file removal fails
379    pub async fn stop(
380        &mut self,
381        engine: &NinjaEngine,
382        shuriken_dir: &Path,
383        mgr: Option<ShurikenManager>,
384    ) -> Result<(), String> {
385        info!("Stopping shuriken {}", self.metadata.name);
386        let lock_path = shuriken_dir.join(".ninja").join("shuriken.lck");
387
388        if self.metadata.shuriken_type == "daemon"
389            && let Some(script_path) = &self.metadata.script_path
390        {
391            let full_script_path = self.resolve_script_path(script_path, shuriken_dir);
392
393            let stem = full_script_path
394                .file_stem()
395                .ok_or_else(|| "Invalid script".to_string())?
396                .to_string_lossy()
397                .to_string();
398            let lock_dir = shuriken_dir.join(".ninja");
399            let compiled_path = lock_dir.join(format!("{stem}.ns"));
400
401            if let Some(mgr) = mgr {
402                {
403                    let mut state = self.state.lock().await;
404                    engine
405                        .execute_function("stop", &compiled_path, Some(shuriken_dir), Some(mgr))
406                        .await
407                        .map_err(|e| {
408                            *state = ShurikenState::Error(e.to_string());
409                            format!("Script stop failed: {}", e)
410                        })?;
411                } // Lock released here
412            }
413
414            if lock_path.exists() {
415                tokio::fs::remove_file(&lock_path)
416                    .await
417                    .map_err(|e| format!("Failed to remove lockfile: {}", e))?;
418            }
419
420            let mut state = self.state.lock().await;
421            *state = ShurikenState::Idle;
422            Ok(())
423        }
424        else {
425            return Err("Shuriken does not have a script path or is not a daemon".to_string());
426        }
427    }
428}