Skip to main content

r2x_python/
plugin_invoker.rs

1//! Plugin invocation and execution
2
3use crate::errors::BridgeError;
4use r2x_logger as logger;
5use r2x_manifest::runtime::{build_runtime_bindings, PluginRole, RuntimeBindings};
6use r2x_manifest::types::Plugin;
7use std::fs;
8use std::io::ErrorKind;
9use std::path::{Component, Path, PathBuf};
10use std::time::Duration;
11
12/// A directory-backed plugin artifact with one JSON entrypoint.
13///
14/// System artifacts can add sidecar files next to the entrypoint. Consumers
15/// must therefore retain and pass the complete bundle directory.
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct ArtifactBundle {
18    root: PathBuf,
19    entrypoint: PathBuf,
20}
21
22impl ArtifactBundle {
23    /// Create a bundle rooted at `root` with a relative JSON entrypoint.
24    pub fn new(
25        root: impl Into<PathBuf>,
26        entrypoint: impl Into<PathBuf>,
27    ) -> Result<Self, BridgeError> {
28        let entrypoint = entrypoint.into();
29        validate_relative_entrypoint(&entrypoint)?;
30        Ok(Self {
31            root: root.into(),
32            entrypoint,
33        })
34    }
35
36    /// Root directory containing the entrypoint and any sidecars.
37    pub fn root(&self) -> &Path {
38        &self.root
39    }
40
41    /// Relative path of the JSON entrypoint within [`Self::root`].
42    pub fn relative_entrypoint(&self) -> &Path {
43        &self.entrypoint
44    }
45
46    /// Absolute or relative filesystem path to the JSON entrypoint.
47    pub fn entrypoint_path(&self) -> PathBuf {
48        self.root.join(&self.entrypoint)
49    }
50}
51
52fn validate_relative_entrypoint(entrypoint: &Path) -> Result<(), BridgeError> {
53    if entrypoint.as_os_str().is_empty() {
54        return Err(BridgeError::InvalidArtifact(
55            "entrypoint cannot be empty".to_string(),
56        ));
57    }
58
59    let mut has_file_name = false;
60    for component in entrypoint.components() {
61        match component {
62            Component::Normal(_) => has_file_name = true,
63            Component::CurDir => {
64                return Err(BridgeError::InvalidArtifact(format!(
65                    "entrypoint cannot contain '.' components: {}",
66                    entrypoint.display()
67                )));
68            }
69            Component::ParentDir => {
70                return Err(BridgeError::InvalidArtifact(format!(
71                    "entrypoint cannot traverse its bundle root: {}",
72                    entrypoint.display()
73                )));
74            }
75            Component::RootDir | Component::Prefix(_) => {
76                return Err(BridgeError::InvalidArtifact(format!(
77                    "entrypoint must be relative to its bundle root: {}",
78                    entrypoint.display()
79                )));
80            }
81        }
82    }
83    if !has_file_name {
84        return Err(BridgeError::InvalidArtifact(
85            "entrypoint must name a file within its bundle".to_string(),
86        ));
87    }
88    Ok(())
89}
90
91/// Type of data produced by an artifact-mode plugin invocation.
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub enum ArtifactOutputKind {
94    /// A `System` persisted through `System.to_json(path)`.
95    System,
96    /// Generic JSON persisted through Python's JSON backend.
97    Json,
98    /// No replacement artifact was emitted.
99    Empty,
100}
101
102/// Result of artifact-mode plugin invocation.
103#[derive(Debug)]
104pub struct PluginArtifactInvocationResult {
105    /// The materialized output type, if any.
106    pub output_kind: ArtifactOutputKind,
107    /// Optional per-phase timings for diagnostics.
108    pub timings: Option<PluginInvocationTimings>,
109}
110
111/// Timings for a plugin invocation phase
112#[derive(Debug)]
113pub struct PluginInvocationTimings {
114    pub python_invocation: Duration,
115    pub serialization: Duration,
116}
117
118/// Result of running a plugin through the Python bridge
119pub struct PluginInvocationResult {
120    /// JSON text emitted by the plugin (may be `"null"`)
121    pub output: String,
122    /// Optional per-phase timings for diagnostics
123    pub timings: Option<PluginInvocationTimings>,
124}
125
126impl crate::python_bridge::Bridge {
127    pub fn invoke_plugin(
128        &self,
129        target: &str,
130        config_json: &str,
131        stdin_json: Option<&str>,
132        plugin_metadata: Option<&Plugin>,
133    ) -> Result<PluginInvocationResult, BridgeError> {
134        let runtime_bindings = plugin_metadata.map(build_runtime_bindings);
135
136        if let Some(bindings) = runtime_bindings.as_ref() {
137            if bindings.role == PluginRole::Upgrader {
138                logger::debug("Routing to upgrader plugin handler");
139                return self.invoke_upgrader_plugin(
140                    target,
141                    config_json,
142                    Some(bindings),
143                    plugin_metadata,
144                );
145            }
146        }
147
148        self.invoke_plugin_regular(target, config_json, stdin_json, runtime_bindings.as_ref())
149    }
150
151    pub fn invoke_plugin_with_bindings(
152        &self,
153        target: &str,
154        config_json: &str,
155        stdin_json: Option<&str>,
156        runtime_bindings: Option<&RuntimeBindings>,
157    ) -> Result<PluginInvocationResult, BridgeError> {
158        if let Some(bindings) = runtime_bindings {
159            if bindings.role == PluginRole::Upgrader {
160                logger::debug("Routing to upgrader plugin handler (runtime bindings)");
161                return self.invoke_upgrader_plugin(target, config_json, Some(bindings), None);
162            }
163        }
164
165        self.invoke_plugin_regular(target, config_json, stdin_json, runtime_bindings)
166    }
167
168    /// Save a System artifact as an infrasys ZIP archive.
169    pub fn save_system_artifact_as_zip(
170        &self,
171        input: &ArtifactBundle,
172        output: &Path,
173    ) -> Result<(), BridgeError> {
174        Self::save_system_artifact_as_zip_native(input, output)
175    }
176
177    /// Invoke a plugin using directory-backed input and output artifacts.
178    ///
179    /// Payload bytes remain in Python. In particular, Systems are loaded and
180    /// written through their path-aware Python APIs so sidecars stay adjacent
181    /// to the JSON entrypoint.
182    pub fn invoke_plugin_with_artifacts(
183        &self,
184        target: &str,
185        config_json: &str,
186        input: Option<&ArtifactBundle>,
187        output: &ArtifactBundle,
188        plugin_metadata: Option<&Plugin>,
189    ) -> Result<PluginArtifactInvocationResult, BridgeError> {
190        let runtime_bindings = plugin_metadata.map(build_runtime_bindings);
191        self.invoke_plugin_with_artifact_bindings(
192            target,
193            config_json,
194            input,
195            output,
196            runtime_bindings.as_ref(),
197        )
198    }
199
200    /// Artifact-mode counterpart of [`Self::invoke_plugin_with_bindings`].
201    pub fn invoke_plugin_with_artifact_bindings(
202        &self,
203        target: &str,
204        config_json: &str,
205        input: Option<&ArtifactBundle>,
206        output: &ArtifactBundle,
207        runtime_bindings: Option<&RuntimeBindings>,
208    ) -> Result<PluginArtifactInvocationResult, BridgeError> {
209        if runtime_bindings.is_some_and(|bindings| bindings.role == PluginRole::Upgrader) {
210            return Err(BridgeError::UnsupportedArtifactMode(
211                "upgrader plugins are not yet supported because registered SYSTEM steps still serialize payloads through Rust".to_string(),
212            ));
213        }
214
215        validate_output_bundle(input, output)?;
216
217        self.invoke_plugin_regular_with_artifacts(
218            target,
219            config_json,
220            input,
221            output,
222            runtime_bindings,
223        )
224    }
225}
226
227fn validate_output_bundle(
228    input: Option<&ArtifactBundle>,
229    output: &ArtifactBundle,
230) -> Result<(), BridgeError> {
231    if input.is_some_and(|input| input.root() == output.root()) {
232        return Err(BridgeError::InvalidArtifact(
233            "input and output bundles must use different roots".to_string(),
234        ));
235    }
236
237    let metadata = match fs::symlink_metadata(output.root()) {
238        Ok(metadata) => metadata,
239        Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
240        Err(error) => return Err(error.into()),
241    };
242    if metadata.file_type().is_symlink() || !metadata.is_dir() {
243        return Err(BridgeError::InvalidArtifact(format!(
244            "output bundle root must be a directory: {}",
245            output.root().display()
246        )));
247    }
248    if fs::read_dir(output.root())?.next().transpose()?.is_some() {
249        return Err(BridgeError::InvalidArtifact(format!(
250            "output bundle root must be empty: {}",
251            output.root().display()
252        )));
253    }
254
255    Ok(())
256}
257
258#[cfg(test)]
259mod tests {
260    use crate::plugin_invoker::*;
261    use crate::python_bridge::Bridge;
262    use r2x_manifest::runtime::{PluginRole, RuntimeBindings};
263    use r2x_manifest::types::PluginType;
264    use std::error::Error;
265    use tempfile::tempdir;
266
267    #[test]
268    fn plugin_invocation_result_basics() {
269        let result = PluginInvocationResult {
270            output: String::new(),
271            timings: None,
272        };
273        assert!(result.output.is_empty());
274    }
275
276    #[test]
277    fn artifact_bundle_rejects_absolute_and_traversing_entrypoints() {
278        let absolute = ArtifactBundle::new("bundle", "/tmp/system.json");
279        assert!(absolute.is_err());
280
281        let traversal = ArtifactBundle::new("bundle", "../system.json");
282        assert!(traversal.is_err());
283
284        let bundle = ArtifactBundle::new("bundle", "nested/system.json");
285        assert!(bundle.is_ok());
286
287        let directory = ArtifactBundle::new("bundle", ".");
288        assert!(directory.is_err());
289
290        let current_directory = ArtifactBundle::new("bundle", "./system.json");
291        assert!(current_directory.is_err());
292    }
293
294    #[test]
295    fn artifact_mode_rejects_upgraders_until_their_payload_path_is_native(
296    ) -> Result<(), BridgeError> {
297        let bridge = Bridge::for_tests();
298        let output = ArtifactBundle::new("bundle", "system.json")?;
299        let bindings = RuntimeBindings {
300            entry_module: "plugin".to_string(),
301            entry_name: "Upgrader".to_string(),
302            plugin_type: PluginType::Class,
303            role: PluginRole::Upgrader,
304            call_method: Some("run".to_string()),
305            config: None,
306            parameters: Vec::new(),
307            requires_store: false,
308        };
309
310        let error = bridge.invoke_plugin_with_artifact_bindings(
311            "plugin:Upgrader",
312            "{}",
313            None,
314            &output,
315            Some(&bindings),
316        );
317        assert!(matches!(
318            error,
319            Err(BridgeError::UnsupportedArtifactMode(_))
320        ));
321        Ok(())
322    }
323
324    #[test]
325    fn artifact_mode_rejects_nonempty_output_bundles() -> Result<(), Box<dyn Error>> {
326        let temp = tempdir()?;
327        let output_root = temp.path().join("output");
328        std::fs::create_dir_all(&output_root)?;
329        std::fs::write(output_root.join("stale.h5"), "stale")?;
330        let output = ArtifactBundle::new(&output_root, "system.json")?;
331
332        let result = Bridge::for_tests().invoke_plugin_with_artifact_bindings(
333            "missing:plugin",
334            "{}",
335            None,
336            &output,
337            None,
338        );
339
340        assert!(matches!(result, Err(BridgeError::InvalidArtifact(_))));
341        assert!(output_root.join("stale.h5").exists());
342        Ok(())
343    }
344}