//! Loading a control-logic program into a provisioned OpenPLC (#183, sub-task 2). //! //! Drives the OpenPLC v3 web UI over HTTP to turn a static control-logic artifact //! into a *running* PLC: log in, upload the program, save it, compile it (MatIEC), //! and start the runtime — at which point OpenPLC opens its Modbus/TCP server on //! 502 and the ICS probe has something to talk to. The endpoint sequence mirrors //! the OpenPLC web UI: `POST /login` → `POST /upload-program` (which hands back a //! server-assigned `prog_file`) → `POST /upload-program-action` → //! `GET /compile-program?file=` → `GET /start_plc`. use std::time::Duration; use crate::error::ExecError; use super::PlcProgram; /// Default OpenPLC program name/description recorded in its UI. const PROG_NAME: &str = "certifai-provisioned"; const PROG_DESCR: &str = "Uploaded by the Certifai provision-and-test scan"; /// Poll interval while waiting for readiness / compilation. const POLL_INTERVAL: Duration = Duration::from_secs(2); /// Wait until the OpenPLC web UI answers (any non-5xx reply to `/login`), or the /// budget elapses. A freshly-started container needs a few seconds to boot. pub async fn wait_ready( http: &reqwest::Client, base_url: &str, budget: Duration, ) -> Result<(), ExecError> { let login = format!("{base_url}/login"); let outcome = tokio::time::timeout(budget, async { loop { if let Ok(resp) = http.get(&login).send().await { if !resp.status().is_server_error() { return; } } tokio::time::sleep(POLL_INTERVAL).await; } }) .await; outcome.map_err(|_| ExecError::Other(format!("OpenPLC at {base_url} did not become ready"))) } /// Log in, upload the program, compile it, and start the runtime. On success the /// OpenPLC Modbus/TCP server is listening on 502. pub async fn load_and_start( http: &reqwest::Client, base_url: &str, user: &str, password: &str, program: &PlcProgram, compile_budget: Duration, ) -> Result<(), ExecError> { login(http, base_url, user, password).await?; let prog_file = upload_program(http, base_url, program).await?; save_program(http, base_url, &prog_file).await?; compile(http, base_url, &prog_file, compile_budget).await?; start(http, base_url).await?; Ok(()) } /// `POST /login` — establishes the session cookie (the client must have a cookie /// store; see the provision-and-test entry point). async fn login( http: &reqwest::Client, base_url: &str, user: &str, password: &str, ) -> Result<(), ExecError> { let resp = http .post(format!("{base_url}/login")) .form(&[("username", user), ("password", password)]) .send() .await?; if resp.status().is_server_error() { return Err(ExecError::Other(format!( "OpenPLC login failed: HTTP {}", resp.status() ))); } Ok(()) } /// `POST /upload-program` (multipart `file`) — OpenPLC stores the program under a /// server-assigned name and returns it in a hidden `prog_file` form field, which /// we parse out for the follow-up save/compile steps. async fn upload_program( http: &reqwest::Client, base_url: &str, program: &PlcProgram, ) -> Result { let part = reqwest::multipart::Part::text(program.source.clone()) .file_name(program.file_name.clone()) .mime_str("application/octet-stream")?; let form = reqwest::multipart::Form::new().part("file", part); let resp = http .post(format!("{base_url}/upload-program")) .multipart(form) .send() .await?; let html = resp.text().await?; parse_prog_file(&html).ok_or_else(|| { ExecError::Other("OpenPLC upload did not return a prog_file handle".to_string()) }) } /// `POST /upload-program-action` — records the uploaded program in OpenPLC's /// program list. `epoch_time` must be close to the server's clock (OpenPLC /// rejects stale timestamps), so we send the current time. async fn save_program( http: &reqwest::Client, base_url: &str, prog_file: &str, ) -> Result<(), ExecError> { let epoch = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) .to_string(); let resp = http .post(format!("{base_url}/upload-program-action")) .form(&[ ("prog_name", PROG_NAME), ("prog_descr", PROG_DESCR), ("prog_file", prog_file), ("epoch_time", &epoch), ]) .send() .await?; if resp.status().is_server_error() { return Err(ExecError::Other(format!( "OpenPLC save-program failed: HTTP {}", resp.status() ))); } Ok(()) } /// `GET /compile-program?file=` then poll `/compilation-logs` until /// MatIEC reports it finished (or the budget elapses). Errors if compilation /// finishes with errors — a program that won't compile can't be started. async fn compile( http: &reqwest::Client, base_url: &str, prog_file: &str, budget: Duration, ) -> Result<(), ExecError> { http.get(format!("{base_url}/compile-program")) .query(&[("file", prog_file)]) .send() .await?; let logs_url = format!("{base_url}/compilation-logs"); let outcome = tokio::time::timeout(budget, async { loop { if let Ok(resp) = http.get(&logs_url).send().await { if let Ok(text) = resp.text().await { if compilation_finished(&text) { return !compilation_failed(&text); } } } tokio::time::sleep(POLL_INTERVAL).await; } }) .await; match outcome { Ok(true) => Ok(()), Ok(false) => Err(ExecError::Other( "OpenPLC compilation finished with errors".to_string(), )), Err(_) => Err(ExecError::Other( "OpenPLC compilation did not finish in time".to_string(), )), } } /// `GET /start_plc` — starts the runtime, opening Modbus/TCP on 502. async fn start(http: &reqwest::Client, base_url: &str) -> Result<(), ExecError> { let resp = http.get(format!("{base_url}/start_plc")).send().await?; if resp.status().is_server_error() { return Err(ExecError::Other(format!( "OpenPLC start_plc failed: HTTP {}", resp.status() ))); } Ok(()) } /// Extract the server-assigned `prog_file` from the `/upload-program` response, /// which embeds it in a hidden input. Attribute order varies, so accept both /// `value=… name='prog_file'` and `name='prog_file' … value=…`. fn parse_prog_file(html: &str) -> Option { // The OpenPLC template renders `value='.st' id='prog_file' // name='prog_file'`. Match the value bound to that input, either order. let value_then_name = regex::Regex::new(r#"(?is)value=['"]([^'"]+)['"][^>]*name=['"]prog_file['"]"#).ok()?; if let Some(c) = value_then_name.captures(html) { return c.get(1).map(|m| m.as_str().to_string()); } let name_then_value = regex::Regex::new(r#"(?is)name=['"]prog_file['"][^>]*value=['"]([^'"]+)['"]"#).ok()?; name_then_value .captures(html) .and_then(|c| c.get(1)) .map(|m| m.as_str().to_string()) } /// Whether the MatIEC compilation log shows the build has finished (either way). fn compilation_finished(log: &str) -> bool { log.contains("Compilation finished") } /// Whether a finished compilation ended in failure. fn compilation_failed(log: &str) -> bool { log.contains("Compilation finished with errors") } #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use super::*; #[test] fn parses_prog_file_value_then_name() { let html = "
"; assert_eq!(parse_prog_file(html), Some("483927.st".to_string())); } #[test] fn parses_prog_file_name_then_value() { let html = r#""#; assert_eq!(parse_prog_file(html), Some("12.st".to_string())); } #[test] fn parse_prog_file_none_when_absent() { assert_eq!(parse_prog_file("no form here"), None); } #[test] fn compilation_predicates() { assert!(!compilation_finished("Compiling...")); assert!(compilation_finished( "...\nCompilation finished successfully!\n" )); assert!(compilation_finished("Compilation finished with errors!")); assert!(compilation_failed("Compilation finished with errors!")); assert!(!compilation_failed("Compilation finished successfully!")); } }