ignition_core/client/webdev.rs
1//! The WebDev client seam (05-03) — the CLI's own gateway-side
2//! surface at `/system/webdev/{project}/cli/{route}` (NOT
3//! `/data/webdev/*` — that prefix does not exist; 05-RESEARCH wire
4//! protocol, live-proven on 8.3.3).
5//!
6//! This module owns the seam's PURE pieces so the trait impl in
7//! [`super`] stays thin delegation (the per-capability-file
8//! convention): the ONE path builder ([`route_url`]), the
9//! presence/version discrimination enum ([`RouteProbe`]), the
10//! shared 200-body envelope parser ([`parse_route_body`]) and its
11//! taxonomy mapping ([`denial_to_error`]), and the deploy zip
12//! builder ([`build_deploy_zip`]).
13//!
14//! THE wire rules pinned here (05-RESEARCH, all live-proven):
15//! - **405 = absent, NOT 404** — missing routes AND missing projects
16//! both answer 405 (Pitfall 1; doctor's Phase-2 404 assumption was
17//! wrong and 05-03 re-pins it).
18//! - **402 = module unlicensed** — a trial-expired gateway's WebDev
19//! servlet answers 402 with an HTML page (cross-verified 8.3.6).
20//! - **Denials ride HTTP 200** — WebDev IGNORES a `status` key in
21//! route returns; every refusal is detectable only from the body
22//! envelope `{ok, data|error}`. The status code alone is NEVER a
23//! success verdict (Pitfall 2).
24//!
25//! [`build_deploy_zip`] packs the embedded 05-01 bundle
26//! ([`crate::webdev`]) into the project-zip the deploy action
27//! uploads through the 03-02 import machinery — scriptExec ONLY with
28//! a SUBSTITUTED secret (fail closed: the template's placeholder
29//! must never ship, the 05-01 structural guarantee enforced here at
30//! the type level).
31
32use std::io::Write;
33
34use serde_json::Value;
35
36use crate::error::CoreError;
37use crate::webdev as bundle;
38
39/// The deploy project the CLI owns wholesale — born from the first
40/// deploy zip, overwrite-replaced by every later deploy (05-RESEARCH
41/// deploy guidance; `--project` overrides it deliberately).
42pub const DEFAULT_PROJECT: &str = "ign-cli";
43
44/// The scriptExec route folder's zip root (the template's static
45/// siblings — 05-01 embedded only the doPost.py TEMPLATE in
46/// `crate::webdev`, so the two gate files embed HERE, at the seam
47/// that packs them).
48const SCRIPT_EXEC_ROUTE_ROOT: &str = "com.inductiveautomation.webdev/resources/cli/scriptExec";
49const SCRIPT_EXEC_RESOURCE_JSON: &str = include_str!(
50 "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/scriptExec/resource.json"
51);
52const SCRIPT_EXEC_CONFIG_JSON: &str = include_str!(
53 "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/scriptExec/config.json"
54);
55
56/// The always-on route folders, in manifest order — DERIVED from
57/// [`bundle::ROUTE_FILES`] so the deploy set, the status sweep, and
58/// the manifest itself can never drift apart.
59pub fn always_on_routes() -> Vec<String> {
60 let mut routes = Vec::new();
61 for (name, _) in bundle::ROUTE_FILES {
62 let Some(rest) = name.strip_prefix("com.inductiveautomation.webdev/resources/cli/") else {
63 continue;
64 };
65 if let Some((route, file)) = rest.rsplit_once('/')
66 && file == "doPost.py"
67 && !routes.iter().any(|known: &String| known == route)
68 {
69 routes.push(route.to_string());
70 }
71 }
72 routes
73}
74
75/// Path builder: `/system/webdev/{project}/cli/{route}` — the `cli/`
76/// folder segment is PART of the route folder path (the wire
77/// protocol's URL shape; the `cli` folder groups the CLI's routes
78/// inside the deploy project).
79pub(crate) fn route_url(project: &str, route: &str) -> String {
80 format!("/system/webdev/{project}/cli/{route}")
81}
82
83/// The testing bundle's URL space — `/system/webdev/{project}/testing/
84/// {route}` (the `run`/`tags` folders live BESIDE `cli/`, not under
85/// it; ADOPT-04).
86pub(crate) fn testing_route_url(project: &str, route: &str) -> String {
87 format!("/system/webdev/{project}/testing/{route}")
88}
89
90/// `POST …/testing/run` with `{"discover": true}` — the module list
91/// (the route is POST-ONLY: the two-method doGet+doPost config does
92/// not register on live 8.3.6 — both methods read disabled → 501;
93/// live-pinned, so discover rides the POST body). Answers
94/// `{"discovered_modules": [...], "count": N}`.
95pub async fn testing_discover(
96 api: &super::ReqwestGatewayApi,
97 project: &str,
98) -> Result<serde_json::Value, CoreError> {
99 api.post_json(
100 &testing_route_url(project, "run"),
101 &serde_json::json!({ "discover": true }),
102 )
103 .await?
104 .json::<serde_json::Value>()
105 .await
106 .map_err(|err| {
107 CoreError::Internal(format!(
108 "testing discover response did not match the expected shape: {err}"
109 ))
110 })
111}
112
113/// `POST …/testing/run` — execute tests (`{"module"|"package"|"format"}`
114/// body; empty body = run_all). The doPost sets HTTP 207 on
115/// failures/errors and 200 on green — BOTH are classified-success
116/// shapes here; the pass/fail verdict rides the BODY
117/// (`passed/failed/errors/total`), never the status line (the
118/// module's own Pitfall-2 rule).
119pub async fn testing_run(
120 api: &super::ReqwestGatewayApi,
121 project: &str,
122 body: &serde_json::Value,
123) -> Result<serde_json::Value, CoreError> {
124 let response = api
125 .post_json(&testing_route_url(project, "run"), body)
126 .await?;
127 response.json::<serde_json::Value>().await.map_err(|err| {
128 CoreError::Internal(format!(
129 "testing run response did not match the expected shape: {err}"
130 ))
131 })
132}
133
134/// The presence/version discrimination — the probe enum. The status
135/// code IS the answer (deliberately NOT run through classify, the
136/// [`super::GatewayApi::webdev_route_status`] precedent); only
137/// transport failures are errors.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub enum RouteProbe {
140 /// 200 + ok body from the version action: deployed and answering
141 /// its handshake `routeVersion`.
142 Present {
143 /// The route's `routeVersion` handshake answer.
144 route_version: String,
145 },
146 /// 405 — the live-proven 8.3 absent marker (missing route or
147 /// missing project; NOT 404).
148 Absent,
149 /// 402 — the WebDev module is installed but unlicensed
150 /// (trial-expired gateway).
151 Unlicensed,
152 /// 401/403 — something answers at the path but rejects the
153 /// credential: present but auth-gated (research Open Question 4's
154 /// resolution: report, never conflate with absent).
155 AuthGated,
156 /// 200 body denial (`{ok:false, error{code,message}}`) — present
157 /// and refusing: the scriptExec gate's `secret_required` /
158 /// `secret_mismatch`, or any other stable route-contract code.
159 Denied {
160 /// The route's machine error code (05-01 contract).
161 code: String,
162 /// The route's human message.
163 message: String,
164 /// The route's Python traceback, when the denial carries one
165 /// (the envelope's optional `error.traceback` — surfaced so
166 /// route-side exceptions are not a black box, 05-08).
167 traceback: Option<String>,
168 },
169}
170
171/// The 200-body verdict shared by [`super::GatewayApi::webdev_route_call`]
172/// and [`super::GatewayApi::webdev_route_probe`].
173#[derive(Debug)]
174pub(crate) enum RouteBody {
175 /// `ok:true` — `data` (Null when the route sent none).
176 Ok(Value),
177 /// `ok:false` — the route's structured refusal (traceback when
178 /// the envelope carried one).
179 Denied {
180 code: String,
181 message: String,
182 traceback: Option<String>,
183 },
184}
185
186/// Parse a 200 body as the route envelope. A body that is not the
187/// `{ok, data|error}` shape is an internal-class honesty error — the
188/// CLI's own routes ALWAYS answer the envelope, so anything else
189/// means the path is not ours (a foreign route or an HTML error page
190/// that smuggled past the status line). Missing `error` fields
191/// degrade to the route contract's generic `route_error` code rather
192/// than guessing.
193pub(crate) fn parse_route_body(body: &str) -> Result<RouteBody, CoreError> {
194 let value: Value = serde_json::from_str(body).map_err(|err| {
195 CoreError::Internal(format!(
196 "webdev route answered a body that is not the {{ok, data|error}} envelope: {err}"
197 ))
198 })?;
199 if value.get("ok").and_then(Value::as_bool) == Some(true) {
200 Ok(RouteBody::Ok(
201 value.get("data").cloned().unwrap_or(Value::Null),
202 ))
203 } else {
204 let code = value
205 .pointer("/error/code")
206 .and_then(Value::as_str)
207 .unwrap_or("route_error")
208 .to_string();
209 let message = value
210 .pointer("/error/message")
211 .and_then(Value::as_str)
212 .unwrap_or("(the route sent no message)")
213 .to_string();
214 let traceback = value
215 .pointer("/error/traceback")
216 .and_then(Value::as_str)
217 .map(str::to_string);
218 Ok(RouteBody::Denied {
219 code,
220 message,
221 traceback,
222 })
223 }
224}
225
226/// Map a body denial onto the taxonomy: the route contract's
227/// `not_found` code reuses the existing [`CoreError::NotFound`] slug
228/// (it means exactly that — the named thing is absent); the alarms
229/// route's `no_alarm_journal` maps to the actionable
230/// [`CoreError::AlarmJournalMissing`] (default rigs ALWAYS deny
231/// history there — the missing journal chain is target state, not a
232/// route bug, 05-06); every other code — known-but-unmapped like
233/// `secret_required`, or unknown from a future route — rides
234/// [`CoreError::WebdevRouteError`] with code + message verbatim, the
235/// stable contract agents branch on. A denial that carried a
236/// traceback gets it appended to the message
237/// (`"\nroute traceback: {tb}"`) — the route-side exception is
238/// visible instead of a black box (05-08); without one the message
239/// is byte-identical to the pre-traceback era.
240pub(crate) fn denial_to_error(
241 code: &str,
242 message: &str,
243 traceback: Option<&str>,
244 endpoint: String,
245) -> CoreError {
246 match code {
247 "not_found" => CoreError::NotFound {
248 endpoint: Some(endpoint),
249 },
250 "no_alarm_journal" => CoreError::AlarmJournalMissing {
251 endpoint: Some(endpoint),
252 },
253 // The tagConfig route's provider-root refusal (07-06): the
254 // route detects the bracket form pre-call and translates the
255 // bare form's 'No RpcContext' throw — the honest
256 // platform-limitation slug over a generic route error (the
257 // no_alarm_journal seam precedent).
258 "provider_root_unsupported" => CoreError::ProviderRootUnsupported {
259 endpoint: Some(endpoint),
260 },
261 _ => {
262 let mut full = message.to_string();
263 if let Some(traceback) = traceback {
264 full.push_str("\nroute traceback: ");
265 full.push_str(traceback);
266 }
267 CoreError::WebdevRouteError {
268 code: code.to_string(),
269 message: full,
270 endpoint: Some(endpoint),
271 }
272 }
273 }
274}
275
276/// Pack the deploy zip: the embedded always-on bundle VERBATIM (the
277/// project title substituted into `project.json`'s `title` ONLY when
278/// `project_title` differs from [`DEFAULT_PROJECT`] — the manifest
279/// already says `ign-cli`), plus — when `with_script_exec` —
280/// scriptExec's three members with the secret SUBSTITUTED into the
281/// template's `__IGN_CLI_SECRET__` marker (exactly-once replace; the
282/// 05-01 contract test pins the marker count).
283///
284/// FAIL CLOSED: `with_script_exec` + `None` secret is an internal
285/// bug guard (the deploy action generates the secret BEFORE packing;
286/// shipping the unsubstituted template would arm the gate with the
287/// publicly-known placeholder). `Some` WITHOUT `with_script_exec` is
288/// tolerated and ignored — a stored profile secret never forces a
289/// scriptExec deploy.
290///
291/// `with_testing` appends the embedded TESTING bundle
292/// ([`bundle::testing::TESTING_FILES`]) after the always-on routes —
293/// the Jython framework + `testing/run`/`testing/tags` WebDev routes,
294/// with every `__IGN_CLI_PROJECT__` marker substituted for
295/// `project_title` (the scriptExec marker pattern, count-pinned).
296///
297/// Members ride fixed `SimpleFileOptions` + deflate (the 05-02
298/// deterministic-zip convention) so identical inputs pack
299/// identically.
300pub fn build_deploy_zip(
301 project_title: &str,
302 with_script_exec: bool,
303 secret: Option<&str>,
304 with_testing: bool,
305) -> Result<Vec<u8>, CoreError> {
306 let script_exec_py = match (with_script_exec, secret) {
307 (false, _) => None,
308 (true, Some(secret)) => {
309 Some(bundle::SCRIPT_EXEC_TEMPLATE.replace("__IGN_CLI_SECRET__", secret))
310 }
311 (true, None) => {
312 return Err(CoreError::Internal(
313 "scriptExec deploy requires a substituted secret — the deploy \
314 action generates the secret before packing (fail-closed guard)"
315 .into(),
316 ));
317 }
318 };
319
320 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
321 let options = zip::write::SimpleFileOptions::default()
322 .compression_method(zip::CompressionMethod::Deflated);
323
324 for (name, contents) in bundle::ROUTE_FILES {
325 let body = if *name == "project.json" && project_title != DEFAULT_PROJECT {
326 retitle_project_json(contents, project_title)?
327 } else {
328 (*contents).to_string()
329 };
330 writer.start_file(*name, options).map_err(zip_write_err)?;
331 writer.write_all(body.as_bytes()).map_err(|err| {
332 CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
333 })?;
334 }
335
336 if let Some(script_exec_py) = script_exec_py {
337 for (name, body) in [
338 (
339 format!("{SCRIPT_EXEC_ROUTE_ROOT}/resource.json"),
340 SCRIPT_EXEC_RESOURCE_JSON.to_string(),
341 ),
342 (
343 format!("{SCRIPT_EXEC_ROUTE_ROOT}/config.json"),
344 SCRIPT_EXEC_CONFIG_JSON.to_string(),
345 ),
346 (
347 format!("{SCRIPT_EXEC_ROUTE_ROOT}/doPost.py"),
348 script_exec_py,
349 ),
350 ] {
351 writer
352 .start_file(name.as_str(), options)
353 .map_err(zip_write_err)?;
354 writer.write_all(body.as_bytes()).map_err(|err| {
355 CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
356 })?;
357 }
358 }
359
360 // The testing bundle (ADOPT-04): every member's marker substituted
361 // with the deploy project — an unsubstituted marker on a gateway
362 // is the bug the count-pin exists to catch before it ships.
363 if with_testing {
364 let marker_total: usize = bundle::testing::TESTING_FILES
365 .iter()
366 .map(|(_, contents)| contents.matches(bundle::testing::PROJECT_MARKER).count())
367 .sum();
368 if marker_total != bundle::testing::PROJECT_MARKER_COUNT {
369 return Err(CoreError::Internal(format!(
370 "testing bundle marker count drifted: found {marker_total}, pinned {} — \
371 a template edit changed the substitution contract",
372 bundle::testing::PROJECT_MARKER_COUNT
373 )));
374 }
375 for (name, contents) in bundle::testing::TESTING_FILES {
376 let body = contents.replace(bundle::testing::PROJECT_MARKER, project_title);
377 writer.start_file(*name, options).map_err(zip_write_err)?;
378 writer.write_all(body.as_bytes()).map_err(|err| {
379 CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
380 })?;
381 }
382 }
383
384 writer
385 .finish()
386 .map_err(zip_write_err)
387 .map(|cursor| cursor.into_inner())
388}
389
390fn zip_write_err(err: zip::result::ZipError) -> CoreError {
391 CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
392}
393
394/// Swap `project.json`'s `title` for a `--project` override — only
395/// `title` moves (name/description/enabled/parent ride verbatim; the
396/// import NAME is the URL's concern, not the manifest's).
397fn retitle_project_json(project_json: &str, title: &str) -> Result<String, CoreError> {
398 let mut value: Value = serde_json::from_str(project_json).map_err(|err| {
399 CoreError::Internal(format!("embedded project.json does not parse: {err}"))
400 })?;
401 value["title"] = Value::String(title.to_string());
402 serde_json::to_string(&value)
403 .map_err(|err| CoreError::Internal(format!("cannot re-serialize project.json: {err}")))
404}
405
406#[cfg(test)]
407mod tests {
408 use super::{
409 DEFAULT_PROJECT, RouteBody, always_on_routes, build_deploy_zip, denial_to_error,
410 parse_route_body,
411 };
412 use crate::error::CoreError;
413
414 /// The route list is DERIVED from the manifest — the four
415 /// always-on folders, manifest order, no drift possible.
416 #[test]
417 fn always_on_routes_derive_from_the_manifest() {
418 assert_eq!(
419 always_on_routes(),
420 vec![
421 "tags".to_string(),
422 "tagConfig".to_string(),
423 "alarms".to_string(),
424 "tagHistory".to_string(),
425 ]
426 );
427 }
428
429 /// Envelope parsing: ok:true yields data; ok:false yields the
430 /// code+message; a non-envelope body is internal-class; missing
431 /// error fields degrade to the generic contract code.
432 #[test]
433 fn parse_route_body_envelope_shapes() {
434 match parse_route_body(r#"{"ok":true,"data":{"routeVersion":"1.0.0"}}"#)
435 .expect("ok body parses")
436 {
437 RouteBody::Ok(data) => {
438 assert_eq!(data["routeVersion"], "1.0.0");
439 }
440 other => panic!("wrong verdict: {other:?}"),
441 }
442
443 match parse_route_body(
444 r#"{"ok":false,"error":{"code":"secret_mismatch","message":"nope"}}"#,
445 )
446 .expect("denial parses")
447 {
448 RouteBody::Denied {
449 code,
450 message,
451 traceback,
452 } => {
453 assert_eq!(code, "secret_mismatch");
454 assert_eq!(message, "nope");
455 assert!(traceback.is_none(), "no traceback on the wire");
456 }
457 other => panic!("wrong verdict: {other:?}"),
458 }
459
460 // A denial carrying the optional traceback keeps it (the
461 // black-box fix's parse half).
462 match parse_route_body(
463 r#"{"ok":false,"error":{"code":"route_error","message":"boom","traceback":"Traceback (most recent call last):\n ValueError: nope"}}"#,
464 )
465 .expect("denial with traceback parses")
466 {
467 RouteBody::Denied { code, traceback, .. } => {
468 assert_eq!(code, "route_error");
469 assert_eq!(
470 traceback.as_deref(),
471 Some("Traceback (most recent call last):\n ValueError: nope")
472 );
473 }
474 other => panic!("wrong verdict: {other:?}"),
475 }
476
477 // ok:true without data → Null (routes may answer bare oks).
478 match parse_route_body(r#"{"ok":true}"#).expect("bare ok parses") {
479 RouteBody::Ok(data) => assert!(data.is_null()),
480 other => panic!("wrong verdict: {other:?}"),
481 }
482
483 // ok:false without an error object → the generic code, never
484 // a guess.
485 match parse_route_body(r#"{"ok":false}"#).expect("bare denial parses") {
486 RouteBody::Denied { code, .. } => assert_eq!(code, "route_error"),
487 other => panic!("wrong verdict: {other:?}"),
488 }
489
490 let err = parse_route_body("<html>jetty</html>").expect_err("non-envelope fails");
491 assert!(matches!(err, CoreError::Internal(_)), "{err}");
492 }
493
494 /// The taxonomy mapping: `not_found` reuses the existing slug;
495 /// everything else (known secret codes included) rides
496 /// `webdev_route_error` verbatim; a traceback appends to the
497 /// message (`\nroute traceback: {tb}`) while its absence keeps
498 /// the message byte-identical.
499 #[test]
500 fn denial_mapping_reuses_not_found_and_rides_the_rest() {
501 let not_found = denial_to_error("not_found", "no such path", None, "/x".into());
502 assert_eq!(not_found.code(), "not_found");
503 assert_eq!(not_found.exit_code(), 6);
504
505 let secret = denial_to_error("secret_required", "missing header", None, "/x".into());
506 assert_eq!(secret.code(), "webdev_route_error");
507 assert_eq!(secret.exit_code(), 6);
508 assert!(secret.to_string().contains("secret_required"));
509 assert!(
510 secret.to_string().contains("missing header"),
511 "no traceback → the message rides VERBATIM (no suffix)"
512 );
513
514 // THE black-box fix: a denial with a traceback shows it —
515 // the "Invalid UUID string" class is diagnosable from CLI
516 // output alone.
517 let blown = denial_to_error(
518 "route_error",
519 "error processing action",
520 Some("java.lang.IllegalArgumentException: Invalid UUID string: 3f2504e0"),
521 "/x".into(),
522 );
523 assert_eq!(blown.code(), "webdev_route_error");
524 let text = blown.to_string();
525 assert!(
526 text.contains("\nroute traceback: java.lang.IllegalArgumentException: Invalid UUID string: 3f2504e0"),
527 "the traceback rides the message: {text}"
528 );
529
530 // The alarms route's journal-missing denial maps to the
531 // actionable slug (default rigs ALWAYS hit it — the missing
532 // journal chain is target state, not a route bug).
533 let journal = denial_to_error(
534 "no_alarm_journal",
535 "No alarm journal profile specified",
536 None,
537 "/system/webdev/ign-cli/cli/alarms".into(),
538 );
539 assert_eq!(journal.code(), "alarm_journal_missing");
540 assert_eq!(journal.exit_code(), 6);
541 assert!(
542 journal.hint().unwrap().contains("journal profile"),
543 "hint names the chain: {journal}"
544 );
545
546 // The tagConfig route's provider-root denial (07-06): the
547 // dedicated slug + exit 6, the message names the subtree
548 // workaround (the fixed Display — the route's own message
549 // stays consistent with it).
550 let root = denial_to_error(
551 "provider_root_unsupported",
552 "provider-root tag paths are not supported on WebDev threads (no RpcContext) -- use a subtree path like [provider]folder",
553 None,
554 "/system/webdev/ign-cli/cli/tagConfig".into(),
555 );
556 assert_eq!(root.code(), "provider_root_unsupported");
557 assert_eq!(root.exit_code(), 6);
558 assert!(
559 root.to_string().contains("subtree like [provider]folder"),
560 "the fixed Display names the subtree workaround: {root}"
561 );
562 }
563
564 /// THE fail-closed guard: scriptExec packing demands a secret —
565 /// `None` + `with_script_exec` refuses BEFORE any zip is built.
566 #[test]
567 fn deploy_zip_fails_closed_without_a_script_exec_secret() {
568 let err = build_deploy_zip(DEFAULT_PROJECT, true, None, false).expect_err("must refuse");
569 assert!(matches!(err, CoreError::Internal(_)), "{err}");
570 assert_eq!(err.exit_code(), 1);
571 }
572}