Skip to main content

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}