1use std::io::Read;
48
49use serde::Serialize;
50
51use crate::actions::webdev::{SCRIPT_EXEC_ROUTE, SECRET_HEADER};
52use crate::client::GatewayApi;
53use crate::config::Config;
54use crate::error::CoreError;
55
56#[derive(Debug, Serialize)]
61pub struct ScriptRunResult {
62 pub stdout: String,
65 pub result: serde_json::Value,
69 #[serde(rename = "elapsedMs")]
73 pub elapsed_ms: u64,
74}
75
76pub async fn script_run(
86 api: &dyn GatewayApi,
87 config: &Config,
88 profile_name: &str,
89 project: &str,
90 code: &str,
91) -> Result<ScriptRunResult, CoreError> {
92 let secret = config
96 .profiles
97 .get(profile_name)
98 .and_then(|profile| profile.webdev_secret.clone())
99 .ok_or_else(|| CoreError::ScriptExecNotConfigured {
100 profile: profile_name.to_string(),
101 })?;
102
103 api.webdev_route_call(
109 project,
110 SCRIPT_EXEC_ROUTE,
111 &serde_json::json!({"action": "version"}),
112 &[(SECRET_HEADER, secret.as_str())],
113 )
114 .await?;
115
116 let data = api
120 .webdev_route_call(
121 project,
122 SCRIPT_EXEC_ROUTE,
123 &serde_json::json!({"action": "exec", "code": code}),
124 &[(SECRET_HEADER, secret.as_str())],
125 )
126 .await?;
127
128 Ok(ScriptRunResult {
129 stdout: data
130 .get("stdout")
131 .and_then(serde_json::Value::as_str)
132 .unwrap_or_default()
133 .to_string(),
134 result: data
135 .get("result")
136 .cloned()
137 .unwrap_or(serde_json::Value::Null),
138 elapsed_ms: data
139 .get("elapsedMs")
140 .and_then(serde_json::Value::as_u64)
141 .unwrap_or_default(),
142 })
143}
144
145pub fn read_script_input(code: Option<&str>, file: Option<&str>) -> Result<String, CoreError> {
154 match (code, file) {
155 (Some(_), Some(_)) => Err(CoreError::InvalidInput {
156 reason: "provide exactly one of --code or --file (not both)".to_string(),
157 }),
158 (Some(code), None) => Ok(code.to_string()),
159 (None, Some("-")) => {
160 let mut buffer = String::new();
161 std::io::stdin()
162 .read_to_string(&mut buffer)
163 .map_err(|err| CoreError::InvalidInput {
164 reason: format!("cannot read stdin: {err}"),
165 })?;
166 Ok(buffer)
167 }
168 (None, Some(file)) => {
169 std::fs::read_to_string(file).map_err(|err| CoreError::InvalidInput {
170 reason: format!("cannot read {file}: {err}"),
171 })
172 }
173 (None, None) => Err(CoreError::InvalidInput {
174 reason: "provide the script via --code PY or --file PATH (--file - reads stdin)"
175 .to_string(),
176 }),
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::{read_script_input, script_run};
183 use crate::client::GatewayApi;
184 use crate::config;
185 use crate::error::CoreError;
186 use std::path::PathBuf;
187
188 type CallLog = std::sync::Arc<std::sync::Mutex<Vec<(String, serde_json::Value)>>>;
191
192 struct ScriptRig {
198 calls: CallLog,
199 answers: fn(&str) -> Result<serde_json::Value, CoreError>,
200 }
201
202 #[async_trait::async_trait]
203 impl GatewayApi for ScriptRig {
204 async fn thread_dump(
205 &self,
206 ) -> Result<crate::client::threads::FormattedThreadDump, CoreError> {
207 unreachable!("not part of this action")
208 }
209 async fn thread_deadlocks(
210 &self,
211 ) -> Result<crate::client::threads::DeadlocksWire, CoreError> {
212 unreachable!("not part of this action")
213 }
214 async fn uninstall_module(
215 &self,
216 _gateway_module_id: &str,
217 _module_id: &str,
218 ) -> Result<(), CoreError> {
219 unreachable!("not part of this action")
220 }
221 async fn bundle_generate(
222 &self,
223 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
224 unreachable!("not part of this action")
225 }
226 async fn bundle_status(
227 &self,
228 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
229 unreachable!("not part of this action")
230 }
231 async fn bundle_download(
232 &self,
233 _out: &std::path::Path,
234 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
235 unreachable!("not part of this action")
236 }
237 async fn tag_provider_list(
238 &self,
239 _query: &crate::client::query::ListQuery,
240 ) -> Result<
241 crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
242 CoreError,
243 > {
244 unreachable!("not part of this action")
245 }
246 async fn tag_provider_find(
247 &self,
248 _name: &str,
249 ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
250 unreachable!("not part of this action")
251 }
252 async fn tag_provider_create(
253 &self,
254 _body: &[crate::client::tags::TagProviderCreate],
255 ) -> Result<(), CoreError> {
256 unreachable!("not part of this action")
257 }
258 async fn tag_provider_delete(
259 &self,
260 _name: &str,
261 _signature: &str,
262 ) -> Result<(), CoreError> {
263 unreachable!("not part of this action")
264 }
265 async fn webdev_route_call(
266 &self,
267 _project: &str,
268 _route: &str,
269 body: &serde_json::Value,
270 _extra_headers: &[(&str, &str)],
271 ) -> Result<serde_json::Value, CoreError> {
272 let action = body["action"].as_str().unwrap_or_default().to_string();
273 self.calls
274 .lock()
275 .expect("calls lock")
276 .push((action.clone(), body.clone()));
277 (self.answers)(&action)
278 }
279 async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
280 unreachable!("not part of this action")
281 }
282 async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
283 unreachable!("not part of this action")
284 }
285 async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
286 unreachable!("not part of this action")
287 }
288 async fn modules(
289 &self,
290 _quarantined: bool,
291 _query: &crate::client::query::ListQuery,
292 ) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
293 {
294 unreachable!("not part of this action")
295 }
296 async fn metrics_current(
297 &self,
298 ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
299 unreachable!("not part of this action")
300 }
301 async fn metrics_historic(
302 &self,
303 ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
304 unreachable!("not part of this action")
305 }
306 async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
307 unreachable!("not part of this action")
308 }
309 async fn designers(
310 &self,
311 _query: &crate::client::query::ListQuery,
312 ) -> Result<
313 crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
314 CoreError,
315 > {
316 unreachable!("not part of this action")
317 }
318 async fn perspective_sessions(
319 &self,
320 _query: &crate::client::query::ListQuery,
321 ) -> Result<
322 crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
323 CoreError,
324 > {
325 unreachable!("not part of this action")
326 }
327 async fn vision_clients(
328 &self,
329 _query: &crate::client::query::ListQuery,
330 ) -> Result<
331 crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
332 CoreError,
333 > {
334 unreachable!("not part of this action")
335 }
336 async fn terminate_perspective_session(
337 &self,
338 _id: &str,
339 _message: Option<&str>,
340 ) -> Result<(), CoreError> {
341 unreachable!("not part of this action")
342 }
343 async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
344 unreachable!("not part of this action")
345 }
346 async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
347 unreachable!("not part of this action")
348 }
349 async fn database_connections(
350 &self,
351 ) -> Result<
352 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
353 CoreError,
354 > {
355 unreachable!("not part of this action")
356 }
357 async fn opc_connections(
358 &self,
359 ) -> Result<
360 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
361 CoreError,
362 > {
363 unreachable!("not part of this action")
364 }
365 async fn logs(
366 &self,
367 _filter: &crate::client::logs::LogQuery,
368 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
369 {
370 unreachable!("not part of this action")
371 }
372 async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
373 unreachable!("not part of this action")
374 }
375 async fn loggers(
376 &self,
377 _query: &crate::client::query::ListQuery,
378 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
379 {
380 unreachable!("not part of this action")
381 }
382 async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
383 unreachable!("not part of this action")
384 }
385 async fn reset_logger_levels(&self) -> Result<(), CoreError> {
386 unreachable!("not part of this action")
387 }
388 async fn restart(&self) -> Result<(), CoreError> {
389 unreachable!("not part of this action")
390 }
391 async fn scan_projects(&self) -> Result<(), CoreError> {
392 unreachable!("not part of this action")
393 }
394 async fn security_properties(
395 &self,
396 ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
397 unreachable!("not part of this action")
398 }
399 async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
400 unreachable!("not part of this action")
401 }
402 async fn webdev_route_probe(
403 &self,
404 _project: &str,
405 _route: &str,
406 _extra_headers: &[(&str, &str)],
407 ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
408 unreachable!("not part of this action")
409 }
410 async fn projects(
411 &self,
412 _query: &crate::client::query::ListQuery,
413 ) -> Result<
414 crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
415 CoreError,
416 > {
417 unreachable!("not part of this action")
418 }
419 async fn project_find(
420 &self,
421 _name: &str,
422 ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
423 unreachable!("not part of this action")
424 }
425 async fn project_create(
426 &self,
427 _body: &crate::client::projects::ProjectCreate,
428 ) -> Result<(), CoreError> {
429 unreachable!("not part of this action")
430 }
431 async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
432 unreachable!("not part of this action")
433 }
434 async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
435 unreachable!("not part of this action")
436 }
437 async fn project_modify(
438 &self,
439 _name: &str,
440 _body: &crate::client::projects::ProjectModify,
441 ) -> Result<(), CoreError> {
442 unreachable!("not part of this action")
443 }
444 async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
445 unreachable!("not part of this action")
446 }
447 async fn project_export_to_file(
448 &self,
449 _name: &str,
450 _out: &std::path::Path,
451 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
452 unreachable!("not part of this action")
453 }
454 async fn project_import(
455 &self,
456 _name: &str,
457 _zip: Vec<u8>,
458 _overwrite: bool,
459 ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
460 unreachable!("not part of this action")
461 }
462 async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
463 unreachable!("not part of this action")
464 }
465 async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
466 unreachable!("not part of this action")
467 }
468 async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
469 unreachable!("not part of this action")
470 }
471 async fn backup_download(
472 &self,
473 _out: &std::path::Path,
474 _backup_type: crate::client::backup::BackupType,
475 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
476 unreachable!("not part of this action")
477 }
478 async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
479 unreachable!("not part of this action")
480 }
481 async fn eam_task_history(
482 &self,
483 _limit: Option<u32>,
484 _search: Option<&str>,
485 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
486 {
487 unreachable!("not part of this action")
488 }
489 async fn eam_task_definitions(
490 &self,
491 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
492 {
493 unreachable!("not part of this action")
494 }
495 async fn eam_task_find(
496 &self,
497 _name: &str,
498 ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
499 unreachable!("not part of this action")
500 }
501 async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
502 unreachable!("not part of this action")
503 }
504 async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
505 unreachable!("not part of this action")
506 }
507 async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
508 unreachable!("not part of this action")
509 }
510 async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
511 unreachable!("not part of this action")
512 }
513 async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
514 unreachable!("not part of this action")
515 }
516 async fn eam_tasks_scheduled(
517 &self,
518 _running: bool,
519 ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
520 unreachable!("not part of this action")
521 }
522 async fn eam_task_modify(
523 &self,
524 _definition: &serde_json::Value,
525 ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
526 unreachable!("not part of this action")
527 }
528 async fn eam_task_delete(
529 &self,
530 _name: &str,
531 _signature: &str,
532 _confirm: bool,
533 ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
534 unreachable!("not part of this action")
535 }
536 async fn api_call(
537 &self,
538 _call: &crate::client::apicall::ApiCallRequest,
539 ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
540 unreachable!("not part of this action")
541 }
542 async fn license_status(
543 &self,
544 ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
545 unreachable!("not part of this action")
546 }
547 async fn redundancy_status(
548 &self,
549 ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
550 unreachable!("not part of this action")
551 }
552 async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
553 unreachable!("not part of this action")
554 }
555 }
556
557 fn temp_config(secret: Option<&str>) -> (tempfile::TempDir, config::Config, PathBuf) {
560 let dir = tempfile::tempdir().expect("tempdir");
561 let path = dir.path().join("config.toml");
562 let secret_line = secret
563 .map(|secret| format!("webdev_secret = \"{secret}\"\n"))
564 .unwrap_or_default();
565 std::fs::write(
566 &path,
567 format!("active = \"dev\"\n\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n{secret_line}"),
568 )
569 .expect("write config");
570 let config = config::load(&path).expect("config loads");
571 (dir, config, path)
572 }
573
574 fn rig(answers: fn(&str) -> Result<serde_json::Value, CoreError>) -> (ScriptRig, CallLog) {
577 let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
578 (
579 ScriptRig {
580 calls: std::sync::Arc::clone(&calls),
581 answers,
582 },
583 calls,
584 )
585 }
586
587 #[tokio::test]
590 async fn missing_secret_refuses_before_any_call() {
591 let (_dir, config, _path) = temp_config(None);
592 let (double, calls) = rig(|_| unreachable!("the gate refuses before any call"));
593 let err = script_run(&double, &config, "dev", "ign-cli", "2+2")
594 .await
595 .expect_err("no secret refuses");
596 assert_eq!(err.code(), "script_exec_not_configured");
597 assert_eq!(err.exit_code(), 6);
598 assert!(
599 err.hint()
600 .unwrap()
601 .contains("ign webdev deploy --with-script-exec"),
602 "hint names the deploy flag: {:?}",
603 err.hint()
604 );
605 assert!(
606 calls.lock().expect("calls lock").is_empty(),
607 "zero route calls"
608 );
609 }
610
611 #[tokio::test]
615 async fn success_round_probes_then_execs_and_maps_the_envelope() {
616 let (_dir, config, _path) = temp_config(Some("aabbcc"));
617 let (double, calls) = rig(|action| {
618 Ok(match action {
619 "version" => serde_json::json!({"routeVersion": "1.0.0", "minCli": "1.0"}),
620 _ => serde_json::json!({
621 "stdout": "hello\n",
622 "result": 4,
623 "elapsedMs": 12,
624 }),
625 })
626 });
627 let result = script_run(&double, &config, "dev", "ign-cli", "print 'hello'\n2+2")
628 .await
629 .expect("exec succeeds");
630 assert_eq!(result.stdout, "hello\n");
631 assert_eq!(result.result, serde_json::json!(4));
632 assert_eq!(result.elapsed_ms, 12);
633
634 let calls = calls.lock().expect("calls lock");
635 assert_eq!(calls.len(), 2, "exactly probe + exec");
636 assert_eq!(calls[0].0, "version");
637 assert_eq!(calls[1].0, "exec");
638 assert_eq!(
639 calls[1].1["code"], "print 'hello'\n2+2",
640 "code rides verbatim"
641 );
642
643 let serialized = serde_json::to_value(&result).expect("serializes");
645 assert_eq!(serialized["stdout"], "hello\n");
646 assert_eq!(serialized["result"], 4);
647 assert_eq!(serialized["elapsedMs"], 12);
648 let mut keys: Vec<&str> = serialized
649 .as_object()
650 .expect("object")
651 .keys()
652 .map(String::as_str)
653 .collect();
654 keys.sort_unstable();
655 assert_eq!(keys, vec!["elapsedMs", "result", "stdout"]);
656 }
657
658 #[tokio::test]
662 async fn absent_answer_fields_default_but_keys_ride() {
663 let (_dir, config, _path) = temp_config(Some("aabbcc"));
664 let (double, _calls) = rig(|_| Ok(serde_json::json!({})));
665 let result = script_run(&double, &config, "dev", "ign-cli", "pass")
666 .await
667 .expect("an empty object still answers");
668 assert_eq!(result.stdout, "");
669 assert_eq!(result.result, serde_json::Value::Null);
670 assert_eq!(result.elapsed_ms, 0);
671 }
672
673 #[tokio::test]
677 async fn probe_denial_surfaces_honestly_without_exec() {
678 let (_dir, config, _path) = temp_config(Some("stale"));
679 let (double, calls) = rig(|action| match action {
680 "version" => Err(CoreError::WebdevRouteError {
681 code: "secret_mismatch".to_string(),
682 message: "scriptExec secret mismatch".to_string(),
683 endpoint: Some("/system/webdev/ign-cli/cli/scriptExec".to_string()),
684 }),
685 _ => unreachable!("exec must not fire after a probe denial"),
686 });
687 let err = script_run(&double, &config, "dev", "ign-cli", "2+2")
688 .await
689 .expect_err("mismatch refuses");
690 assert_eq!(err.code(), "webdev_route_error");
691 assert_eq!(err.exit_code(), 6);
692 assert!(
693 err.hint().unwrap().contains("--rotate-secret"),
694 "the existing hint carries the redeploy/rotate advice: {:?}",
695 err.hint()
696 );
697 let calls = calls.lock().expect("calls lock");
698 assert_eq!(calls.len(), 1, "only the probe ran");
699 assert_eq!(calls[0].0, "version");
700 }
701
702 #[test]
706 fn read_script_input_resolves_the_three_forms() {
707 assert_eq!(read_script_input(Some("2+2"), None).expect("code"), "2+2");
709 let dir = tempfile::tempdir().expect("tempdir");
711 let file = dir.path().join("snippet.py");
712 std::fs::write(&file, "print 'hi'\n").expect("write snippet");
713 assert_eq!(
714 read_script_input(None, file.to_str()).expect("file"),
715 "print 'hi'\n"
716 );
717 let err = read_script_input(Some("2+2"), file.to_str()).expect_err("both refuse");
719 assert_eq!(err.code(), "invalid_input");
720 assert_eq!(err.exit_code(), 2);
721 assert!(
722 err.to_string().contains("--code") && err.to_string().contains("--file"),
723 "reason names both flags: {err}"
724 );
725 let err = read_script_input(None, None).expect_err("neither refuses");
727 assert_eq!(err.code(), "invalid_input");
728 assert!(err.to_string().contains("--file -"), "stdin named: {err}");
729 let err = read_script_input(None, Some("/nonexistent/snippet.py")).expect_err("miss");
731 assert_eq!(err.code(), "invalid_input");
732 assert!(
733 err.to_string().contains("/nonexistent/snippet.py"),
734 "reason names the file: {err}"
735 );
736 }
737}