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 presence/version discrimination — the probe enum. The status
84/// code IS the answer (deliberately NOT run through classify, the
85/// [`super::GatewayApi::webdev_route_status`] precedent); only
86/// transport failures are errors.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum RouteProbe {
89 /// 200 + ok body from the version action: deployed and answering
90 /// its handshake `routeVersion`.
91 Present {
92 /// The route's `routeVersion` handshake answer.
93 route_version: String,
94 },
95 /// 405 — the live-proven 8.3 absent marker (missing route or
96 /// missing project; NOT 404).
97 Absent,
98 /// 402 — the WebDev module is installed but unlicensed
99 /// (trial-expired gateway).
100 Unlicensed,
101 /// 401/403 — something answers at the path but rejects the
102 /// credential: present but auth-gated (research Open Question 4's
103 /// resolution: report, never conflate with absent).
104 AuthGated,
105 /// 200 body denial (`{ok:false, error{code,message}}`) — present
106 /// and refusing: the scriptExec gate's `secret_required` /
107 /// `secret_mismatch`, or any other stable route-contract code.
108 Denied {
109 /// The route's machine error code (05-01 contract).
110 code: String,
111 /// The route's human message.
112 message: String,
113 /// The route's Python traceback, when the denial carries one
114 /// (the envelope's optional `error.traceback` — surfaced so
115 /// route-side exceptions are not a black box, 05-08).
116 traceback: Option<String>,
117 },
118}
119
120/// The 200-body verdict shared by [`super::GatewayApi::webdev_route_call`]
121/// and [`super::GatewayApi::webdev_route_probe`].
122#[derive(Debug)]
123pub(crate) enum RouteBody {
124 /// `ok:true` — `data` (Null when the route sent none).
125 Ok(Value),
126 /// `ok:false` — the route's structured refusal (traceback when
127 /// the envelope carried one).
128 Denied {
129 code: String,
130 message: String,
131 traceback: Option<String>,
132 },
133}
134
135/// Parse a 200 body as the route envelope. A body that is not the
136/// `{ok, data|error}` shape is an internal-class honesty error — the
137/// CLI's own routes ALWAYS answer the envelope, so anything else
138/// means the path is not ours (a foreign route or an HTML error page
139/// that smuggled past the status line). Missing `error` fields
140/// degrade to the route contract's generic `route_error` code rather
141/// than guessing.
142pub(crate) fn parse_route_body(body: &str) -> Result<RouteBody, CoreError> {
143 let value: Value = serde_json::from_str(body).map_err(|err| {
144 CoreError::Internal(format!(
145 "webdev route answered a body that is not the {{ok, data|error}} envelope: {err}"
146 ))
147 })?;
148 if value.get("ok").and_then(Value::as_bool) == Some(true) {
149 Ok(RouteBody::Ok(
150 value.get("data").cloned().unwrap_or(Value::Null),
151 ))
152 } else {
153 let code = value
154 .pointer("/error/code")
155 .and_then(Value::as_str)
156 .unwrap_or("route_error")
157 .to_string();
158 let message = value
159 .pointer("/error/message")
160 .and_then(Value::as_str)
161 .unwrap_or("(the route sent no message)")
162 .to_string();
163 let traceback = value
164 .pointer("/error/traceback")
165 .and_then(Value::as_str)
166 .map(str::to_string);
167 Ok(RouteBody::Denied {
168 code,
169 message,
170 traceback,
171 })
172 }
173}
174
175/// Map a body denial onto the taxonomy: the route contract's
176/// `not_found` code reuses the existing [`CoreError::NotFound`] slug
177/// (it means exactly that — the named thing is absent); the alarms
178/// route's `no_alarm_journal` maps to the actionable
179/// [`CoreError::AlarmJournalMissing`] (default rigs ALWAYS deny
180/// history there — the missing journal chain is target state, not a
181/// route bug, 05-06); every other code — known-but-unmapped like
182/// `secret_required`, or unknown from a future route — rides
183/// [`CoreError::WebdevRouteError`] with code + message verbatim, the
184/// stable contract agents branch on. A denial that carried a
185/// traceback gets it appended to the message
186/// (`"\nroute traceback: {tb}"`) — the route-side exception is
187/// visible instead of a black box (05-08); without one the message
188/// is byte-identical to the pre-traceback era.
189pub(crate) fn denial_to_error(
190 code: &str,
191 message: &str,
192 traceback: Option<&str>,
193 endpoint: String,
194) -> CoreError {
195 match code {
196 "not_found" => CoreError::NotFound {
197 endpoint: Some(endpoint),
198 },
199 "no_alarm_journal" => CoreError::AlarmJournalMissing {
200 endpoint: Some(endpoint),
201 },
202 // The tagConfig route's provider-root refusal (07-06): the
203 // route detects the bracket form pre-call and translates the
204 // bare form's 'No RpcContext' throw — the honest
205 // platform-limitation slug over a generic route error (the
206 // no_alarm_journal seam precedent).
207 "provider_root_unsupported" => CoreError::ProviderRootUnsupported {
208 endpoint: Some(endpoint),
209 },
210 _ => {
211 let mut full = message.to_string();
212 if let Some(traceback) = traceback {
213 full.push_str("\nroute traceback: ");
214 full.push_str(traceback);
215 }
216 CoreError::WebdevRouteError {
217 code: code.to_string(),
218 message: full,
219 endpoint: Some(endpoint),
220 }
221 }
222 }
223}
224
225/// Pack the deploy zip: the embedded always-on bundle VERBATIM (the
226/// project title substituted into `project.json`'s `title` ONLY when
227/// `project_title` differs from [`DEFAULT_PROJECT`] — the manifest
228/// already says `ign-cli`), plus — when `with_script_exec` —
229/// scriptExec's three members with the secret SUBSTITUTED into the
230/// template's `__IGN_CLI_SECRET__` marker (exactly-once replace; the
231/// 05-01 contract test pins the marker count).
232///
233/// FAIL CLOSED: `with_script_exec` + `None` secret is an internal
234/// bug guard (the deploy action generates the secret BEFORE packing;
235/// shipping the unsubstituted template would arm the gate with the
236/// publicly-known placeholder). `Some` WITHOUT `with_script_exec` is
237/// tolerated and ignored — a stored profile secret never forces a
238/// scriptExec deploy.
239///
240/// Members ride fixed `SimpleFileOptions` + deflate (the 05-02
241/// deterministic-zip convention) so identical inputs pack
242/// identically.
243pub fn build_deploy_zip(
244 project_title: &str,
245 with_script_exec: bool,
246 secret: Option<&str>,
247) -> Result<Vec<u8>, CoreError> {
248 let script_exec_py = match (with_script_exec, secret) {
249 (false, _) => None,
250 (true, Some(secret)) => {
251 Some(bundle::SCRIPT_EXEC_TEMPLATE.replace("__IGN_CLI_SECRET__", secret))
252 }
253 (true, None) => {
254 return Err(CoreError::Internal(
255 "scriptExec deploy requires a substituted secret — the deploy \
256 action generates the secret before packing (fail-closed guard)"
257 .into(),
258 ));
259 }
260 };
261
262 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
263 let options = zip::write::SimpleFileOptions::default()
264 .compression_method(zip::CompressionMethod::Deflated);
265
266 for (name, contents) in bundle::ROUTE_FILES {
267 let body = if *name == "project.json" && project_title != DEFAULT_PROJECT {
268 retitle_project_json(contents, project_title)?
269 } else {
270 (*contents).to_string()
271 };
272 writer.start_file(*name, options).map_err(zip_write_err)?;
273 writer.write_all(body.as_bytes()).map_err(|err| {
274 CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
275 })?;
276 }
277
278 if let Some(script_exec_py) = script_exec_py {
279 for (name, body) in [
280 (
281 format!("{SCRIPT_EXEC_ROUTE_ROOT}/resource.json"),
282 SCRIPT_EXEC_RESOURCE_JSON.to_string(),
283 ),
284 (
285 format!("{SCRIPT_EXEC_ROUTE_ROOT}/config.json"),
286 SCRIPT_EXEC_CONFIG_JSON.to_string(),
287 ),
288 (
289 format!("{SCRIPT_EXEC_ROUTE_ROOT}/doPost.py"),
290 script_exec_py,
291 ),
292 ] {
293 writer
294 .start_file(name.as_str(), options)
295 .map_err(zip_write_err)?;
296 writer.write_all(body.as_bytes()).map_err(|err| {
297 CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
298 })?;
299 }
300 }
301
302 writer
303 .finish()
304 .map_err(zip_write_err)
305 .map(|cursor| cursor.into_inner())
306}
307
308fn zip_write_err(err: zip::result::ZipError) -> CoreError {
309 CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
310}
311
312/// Swap `project.json`'s `title` for a `--project` override — only
313/// `title` moves (name/description/enabled/parent ride verbatim; the
314/// import NAME is the URL's concern, not the manifest's).
315fn retitle_project_json(project_json: &str, title: &str) -> Result<String, CoreError> {
316 let mut value: Value = serde_json::from_str(project_json).map_err(|err| {
317 CoreError::Internal(format!("embedded project.json does not parse: {err}"))
318 })?;
319 value["title"] = Value::String(title.to_string());
320 serde_json::to_string(&value)
321 .map_err(|err| CoreError::Internal(format!("cannot re-serialize project.json: {err}")))
322}
323
324#[cfg(test)]
325mod tests {
326 use super::{
327 DEFAULT_PROJECT, RouteBody, always_on_routes, build_deploy_zip, denial_to_error,
328 parse_route_body,
329 };
330 use crate::error::CoreError;
331
332 /// The route list is DERIVED from the manifest — the four
333 /// always-on folders, manifest order, no drift possible.
334 #[test]
335 fn always_on_routes_derive_from_the_manifest() {
336 assert_eq!(
337 always_on_routes(),
338 vec![
339 "tags".to_string(),
340 "tagConfig".to_string(),
341 "alarms".to_string(),
342 "tagHistory".to_string(),
343 ]
344 );
345 }
346
347 /// Envelope parsing: ok:true yields data; ok:false yields the
348 /// code+message; a non-envelope body is internal-class; missing
349 /// error fields degrade to the generic contract code.
350 #[test]
351 fn parse_route_body_envelope_shapes() {
352 match parse_route_body(r#"{"ok":true,"data":{"routeVersion":"1.0.0"}}"#)
353 .expect("ok body parses")
354 {
355 RouteBody::Ok(data) => {
356 assert_eq!(data["routeVersion"], "1.0.0");
357 }
358 other => panic!("wrong verdict: {other:?}"),
359 }
360
361 match parse_route_body(
362 r#"{"ok":false,"error":{"code":"secret_mismatch","message":"nope"}}"#,
363 )
364 .expect("denial parses")
365 {
366 RouteBody::Denied {
367 code,
368 message,
369 traceback,
370 } => {
371 assert_eq!(code, "secret_mismatch");
372 assert_eq!(message, "nope");
373 assert!(traceback.is_none(), "no traceback on the wire");
374 }
375 other => panic!("wrong verdict: {other:?}"),
376 }
377
378 // A denial carrying the optional traceback keeps it (the
379 // black-box fix's parse half).
380 match parse_route_body(
381 r#"{"ok":false,"error":{"code":"route_error","message":"boom","traceback":"Traceback (most recent call last):\n ValueError: nope"}}"#,
382 )
383 .expect("denial with traceback parses")
384 {
385 RouteBody::Denied { code, traceback, .. } => {
386 assert_eq!(code, "route_error");
387 assert_eq!(
388 traceback.as_deref(),
389 Some("Traceback (most recent call last):\n ValueError: nope")
390 );
391 }
392 other => panic!("wrong verdict: {other:?}"),
393 }
394
395 // ok:true without data → Null (routes may answer bare oks).
396 match parse_route_body(r#"{"ok":true}"#).expect("bare ok parses") {
397 RouteBody::Ok(data) => assert!(data.is_null()),
398 other => panic!("wrong verdict: {other:?}"),
399 }
400
401 // ok:false without an error object → the generic code, never
402 // a guess.
403 match parse_route_body(r#"{"ok":false}"#).expect("bare denial parses") {
404 RouteBody::Denied { code, .. } => assert_eq!(code, "route_error"),
405 other => panic!("wrong verdict: {other:?}"),
406 }
407
408 let err = parse_route_body("<html>jetty</html>").expect_err("non-envelope fails");
409 assert!(matches!(err, CoreError::Internal(_)), "{err}");
410 }
411
412 /// The taxonomy mapping: `not_found` reuses the existing slug;
413 /// everything else (known secret codes included) rides
414 /// `webdev_route_error` verbatim; a traceback appends to the
415 /// message (`\nroute traceback: {tb}`) while its absence keeps
416 /// the message byte-identical.
417 #[test]
418 fn denial_mapping_reuses_not_found_and_rides_the_rest() {
419 let not_found = denial_to_error("not_found", "no such path", None, "/x".into());
420 assert_eq!(not_found.code(), "not_found");
421 assert_eq!(not_found.exit_code(), 6);
422
423 let secret = denial_to_error("secret_required", "missing header", None, "/x".into());
424 assert_eq!(secret.code(), "webdev_route_error");
425 assert_eq!(secret.exit_code(), 6);
426 assert!(secret.to_string().contains("secret_required"));
427 assert!(
428 secret.to_string().contains("missing header"),
429 "no traceback → the message rides VERBATIM (no suffix)"
430 );
431
432 // THE black-box fix: a denial with a traceback shows it —
433 // the "Invalid UUID string" class is diagnosable from CLI
434 // output alone.
435 let blown = denial_to_error(
436 "route_error",
437 "error processing action",
438 Some("java.lang.IllegalArgumentException: Invalid UUID string: 3f2504e0"),
439 "/x".into(),
440 );
441 assert_eq!(blown.code(), "webdev_route_error");
442 let text = blown.to_string();
443 assert!(
444 text.contains("\nroute traceback: java.lang.IllegalArgumentException: Invalid UUID string: 3f2504e0"),
445 "the traceback rides the message: {text}"
446 );
447
448 // The alarms route's journal-missing denial maps to the
449 // actionable slug (default rigs ALWAYS hit it — the missing
450 // journal chain is target state, not a route bug).
451 let journal = denial_to_error(
452 "no_alarm_journal",
453 "No alarm journal profile specified",
454 None,
455 "/system/webdev/ign-cli/cli/alarms".into(),
456 );
457 assert_eq!(journal.code(), "alarm_journal_missing");
458 assert_eq!(journal.exit_code(), 6);
459 assert!(
460 journal.hint().unwrap().contains("journal profile"),
461 "hint names the chain: {journal}"
462 );
463
464 // The tagConfig route's provider-root denial (07-06): the
465 // dedicated slug + exit 6, the message names the subtree
466 // workaround (the fixed Display — the route's own message
467 // stays consistent with it).
468 let root = denial_to_error(
469 "provider_root_unsupported",
470 "provider-root tag paths are not supported on WebDev threads (no RpcContext) -- use a subtree path like [provider]folder",
471 None,
472 "/system/webdev/ign-cli/cli/tagConfig".into(),
473 );
474 assert_eq!(root.code(), "provider_root_unsupported");
475 assert_eq!(root.exit_code(), 6);
476 assert!(
477 root.to_string().contains("subtree like [provider]folder"),
478 "the fixed Display names the subtree workaround: {root}"
479 );
480 }
481
482 /// THE fail-closed guard: scriptExec packing demands a secret —
483 /// `None` + `with_script_exec` refuses BEFORE any zip is built.
484 #[test]
485 fn deploy_zip_fails_closed_without_a_script_exec_secret() {
486 let err = build_deploy_zip(DEFAULT_PROJECT, true, None).expect_err("must refuse");
487 assert!(matches!(err, CoreError::Internal(_)), "{err}");
488 assert_eq!(err.exit_code(), 1);
489 }
490}