Skip to main content

aion_package/declared_command/
compat.rs

1//! Reading the PRIOR archive form of a declared command.
2//!
3//! # Why this exists
4//!
5//! v0.27.0 reshaped [`DeclaredCommandContract`] around body LINES (the just
6//! recipe surface). Archives deployed under the prior form — a `program`
7//! word list plus `args` slots, emitted by the clause spelling this language
8//! no longer has — are already durable in real stores, and the release rule
9//! is that a store migrates itself or nothing ships: boot recovery, serve
10//! dispatch and the deployed census all sit on the READ path of those
11//! archives, and a contract that refused to deserialize there would make a
12//! deployed package unreadable by upgrading the binary.
13//!
14//! So the DESERIALIZER accepts both forms. The current form reads as
15//! written. The prior form is translated at read time into the current
16//! shape, faithfully where the two surfaces mean the same thing:
17//!
18//! - the `program` words become leading literal argv slots and the `args`
19//!   slots follow them, as ONE body line — exactly the argv the prior
20//!   executor built, in the same order, with each slot's recorded
21//!   `admits_leading_dash` fact preserved;
22//! - a parameter default that is literal text carries over unchanged;
23//! - a literal environment binding carries over; the prior `hardening path`
24//!   value becomes a literal `PATH` binding, which is what it did — the
25//!   executor clears the child environment and sets `PATH`, and a declared
26//!   binding of that name overrides it;
27//! - the prior `timeout` clause translates to nothing, deliberately: the
28//!   enforcement machinery was deleted with the surface, and carrying a
29//!   number nothing enforces would be a claim the runtime cannot honour.
30//!   The drop is LOGGED at open, because an upgrade that silently turns a
31//!   bounded command into an unbounded one is a silence the operator pays
32//!   for.
33//!
34//! # The constructs with no faithful translation
35//!
36//! Four prior constructs have no current spelling at all. None of them can
37//! be translated into something that still means what its author wrote:
38//!
39//! - an environment binding whose value interpolates a parameter — an
40//!   environment value is literal document text now, with no parameters in
41//!   scope;
42//! - a `hardening path` whose value interpolates a parameter, which is the
43//!   same construct wearing the name `PATH`;
44//! - a LIST parameter — the prior executor splatted a sole-hole list value
45//!   into one argv element per item, and the current surface renders one
46//!   fill as exactly one element, so there is no argv a list could produce;
47//! - a parameter default that interpolates another parameter — a default is
48//!   literal text now, so there is no value left to fall back on.
49//!
50//! All four take the SAME path, and it is deliberately not a read failure.
51//! The construct is named onto
52//! [`DeclaredCommandContract::prior_form_refusal`], each occurrence is warned
53//! about once at open (so the operator hears it at upgrade time, not at three
54//! in the morning on a schedule), the archive still opens, lists and censuses
55//! everywhere, and only an attempt to EXECUTE the command refuses — naming
56//! the command, the construct and the one cure, which is to redeploy the
57//! document under the current spelling
58//! ([`super::error::RenderError::PriorFormUnrenderable`]).
59//!
60//! This module is the whole compatibility surface: nothing else in the crate
61//! knows the prior form exists, and the emitter can never produce it.
62
63use serde::Deserialize;
64use serde::de::Error as _;
65
66use super::contract::{
67    ArgvSlot, CommandLineContract, CommandParameterContract, DeclaredCommandContract,
68    EnvBindingContract,
69};
70use super::template::{FillPiece, FillTemplate};
71
72// This implementation buffers the entry into a `serde_json::Value` and reads
73// the shape off the buffered keys, which REQUIRES a self-describing format.
74// The archive's contract entry is JSON and always has been; a future compact
75// codec (one whose deserializer cannot answer `deserialize_any`) would make
76// the buffering step fail rather than mis-route, but it would also silently
77// take away the only signal that tells the two forms apart — so a codec change
78// here is a change to this router, not a change underneath it.
79impl<'de> Deserialize<'de> for DeclaredCommandContract {
80    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
81    where
82        D: serde::Deserializer<'de>,
83    {
84        // The two shapes are told apart by the field that names each: the
85        // prior form always carries `program` (no skip, so it is present even
86        // when empty) and never `lines`; the current form always carries
87        // `lines` and never `program`. Routing on the bytes keeps each shape's
88        // own field errors precise, where an untagged union would report only
89        // "no variant matched".
90        let value = serde_json::Value::deserialize(deserializer)?;
91        let has_program = value.get("program").is_some();
92        let has_lines = value.get("lines").is_some();
93        if has_program && has_lines {
94            // Neither reading is defensible: taking the prior path would
95            // discard the body lines in silence, and taking the current path
96            // would discard the program words. An entry no emitter of either
97            // form could have written is refused by name.
98            return Err(D::Error::custom(
99                "this declared command carries both `program` (the prior archive form) and \
100                 `lines` (the current form); no emitter writes both, and reading either one \
101                 would silently discard the other, so the entry is refused rather than guessed",
102            ));
103        }
104        if has_program {
105            let prior = PriorForm::deserialize(&value).map_err(D::Error::custom)?;
106            return Ok(prior.translated());
107        }
108        CurrentForm::deserialize(&value)
109            .map_err(D::Error::custom)
110            .map(CurrentForm::into_contract)
111    }
112}
113
114/// The current wire shape, mirrored for derived deserialization (the real
115/// struct's `Deserialize` is the hand-written router above).
116///
117/// `deny_unknown_fields` because this mirror names every field the current
118/// `Serialize` impl writes, and the only writer of a current-form entry is
119/// that impl: a key this shape does not name was never emitted here, and
120/// accepting it would let a hand-edited or half-translated entry read as
121/// something narrower than it says.
122#[derive(Deserialize)]
123#[serde(deny_unknown_fields)]
124struct CurrentForm {
125    name: String,
126    #[serde(default)]
127    parameters: Vec<CommandParameterContract>,
128    lines: Vec<CommandLineContract>,
129    #[serde(default)]
130    env: Vec<EnvBindingContract>,
131    #[serde(default)]
132    cwd: Option<String>,
133    #[serde(default)]
134    prior_form_refusal: Option<String>,
135}
136
137impl CurrentForm {
138    fn into_contract(self) -> DeclaredCommandContract {
139        DeclaredCommandContract {
140            name: self.name,
141            parameters: self.parameters,
142            lines: self.lines,
143            env: self.env,
144            cwd: self.cwd,
145            prior_form_refusal: self.prior_form_refusal,
146        }
147    }
148}
149
150/// One parameter as the prior form declared it: the default is a fill
151/// TEMPLATE rather than literal text, and the shape flag says whether the
152/// parameter took a LIST of values.
153///
154/// `list` is required, not defaulted: the prior emitter wrote the flag on
155/// every parameter it ever emitted (a plain `bool` with no skip), and
156/// defaulting it would mean reading a list parameter as a single-value one —
157/// which is the exact silence this reader exists to break.
158#[derive(Deserialize)]
159#[serde(deny_unknown_fields)]
160struct PriorParameter {
161    name: String,
162    list: bool,
163    #[serde(default)]
164    default: Option<FillTemplate>,
165}
166
167/// One environment binding as the prior form declared it: the value is a
168/// fill template.
169#[derive(Deserialize)]
170#[serde(deny_unknown_fields)]
171struct PriorEnv {
172    name: String,
173    value: FillTemplate,
174}
175
176/// The prior wire shape. The `timeout_ms`/`timeout_owner` pair is read only
177/// to be REPORTED: the enforcement machinery was deleted with the surface,
178/// so the bound translates to nothing — and an upgrade that silently turned
179/// a bounded command into an unbounded one would be a silence the operator
180/// pays for, so the drop is logged at open.
181///
182/// `deny_unknown_fields` because these nine names are the COMPLETE field set
183/// the prior form ever serialized, verified against the released source of
184/// both commits that ever emitted it (`e7a4095dd` and `a5ce33a78`, unchanged
185/// between them): `name`, `parameters`, `program`, `args`, `env`, `cwd`,
186/// `hardened_path`, `timeout_ms`, `timeout_owner`. A key outside that set
187/// cannot have come from a prior-form emitter, so reading it as a prior-form
188/// command would be reading something else entirely.
189#[derive(Deserialize)]
190#[serde(deny_unknown_fields)]
191struct PriorForm {
192    name: String,
193    #[serde(default)]
194    parameters: Vec<PriorParameter>,
195    program: Vec<String>,
196    #[serde(default)]
197    args: Vec<ArgvSlot>,
198    #[serde(default)]
199    env: Vec<PriorEnv>,
200    #[serde(default)]
201    cwd: Option<String>,
202    #[serde(default)]
203    hardened_path: Option<FillTemplate>,
204    #[serde(default)]
205    timeout_ms: Option<i64>,
206    #[serde(default)]
207    timeout_owner: Option<String>,
208}
209
210impl PriorForm {
211    /// Translate into the current shape, per the module rules.
212    fn translated(self) -> DeclaredCommandContract {
213        let Self {
214            name,
215            parameters,
216            program,
217            args,
218            env,
219            cwd,
220            hardened_path,
221            timeout_ms,
222            timeout_owner,
223        } = self;
224
225        // An upgrade must not change behaviour in silence: the operator whose
226        // command just went from bounded to unbounded learns it here, once
227        // per open, from the product — before anything dispatches.
228        if let Some(timeout_ms) = timeout_ms {
229            tracing::warn!(
230                operation = "prior_form_command_read",
231                command = %name,
232                timeout_ms,
233                owner = timeout_owner.as_deref().unwrap_or("unstated"),
234                "this declared command was deployed with a time limit of its own, and a command \
235                 no longer carries one: the limit is gone and the command now runs for as long \
236                 as it takes. Bound the work with the workflow's activity timeout instead, then \
237                 redeploy the document"
238            );
239        }
240
241        let mut untranslated = Untranslated {
242            command: &name,
243            first: None,
244        };
245
246        let mut slots: Vec<ArgvSlot> = program
247            .into_iter()
248            .map(|word| ArgvSlot {
249                fill: FillTemplate::literal(word.clone()),
250                label: word,
251                // The prior executor pushed program words verbatim ahead of
252                // every guard, which is exactly what a literal slot does.
253                admits_leading_dash: true,
254            })
255            .collect();
256        slots.extend(args);
257
258        let parameters = translate_parameters(parameters, &mut untranslated);
259        let translated_env = translate_env(env, hardened_path, &mut untranslated);
260
261        let refusal = untranslated.first;
262
263        DeclaredCommandContract {
264            name,
265            parameters,
266            lines: vec![CommandLineContract { slots }],
267            env: translated_env,
268            cwd,
269            prior_form_refusal: refusal,
270        }
271    }
272}
273
274/// Translate the prior parameter list, naming what cannot come across.
275fn translate_parameters(
276    parameters: Vec<PriorParameter>,
277    untranslated: &mut Untranslated<'_>,
278) -> Vec<CommandParameterContract> {
279    parameters
280        .into_iter()
281        .map(|parameter| {
282            // A parameter that took several values has no current rendering at
283            // all: the prior executor turned one such value into one word per
284            // item, and a fill is exactly one word now.
285            if parameter.list {
286                untranslated.record(
287                    format!("the list parameter `{}`", parameter.name),
288                    "under the old spelling this parameter took several values at once and each \
289                     became its own word on the command line; a value now becomes exactly one \
290                     word, so there is no command line left to build",
291                );
292            }
293            // A literal default carries over as written. A default built out of
294            // other parameters has no current spelling — a default is plain
295            // text now — and reading it as "no default" would be a false
296            // sentence about the archive.
297            let default = match parameter.default.as_ref().map(literal_text) {
298                None => None,
299                Some(Some(literal)) => Some(literal),
300                Some(None) => {
301                    untranslated.record(
302                        format!("the default of parameter `{}`", parameter.name),
303                        "under the old spelling this parameter's default was built from the \
304                         values of other parameters; a default is plain text now, so there is no \
305                         value left for it to fall back on",
306                    );
307                    None
308                }
309            };
310            CommandParameterContract {
311                name: parameter.name,
312                default,
313            }
314        })
315        .collect()
316}
317
318/// Translate the prior environment bindings and the prior `hardening path`,
319/// naming what cannot come across.
320fn translate_env(
321    env: Vec<PriorEnv>,
322    hardened_path: Option<FillTemplate>,
323    untranslated: &mut Untranslated<'_>,
324) -> Vec<EnvBindingContract> {
325    let mut translated: Vec<EnvBindingContract> = Vec::new();
326    for binding in env {
327        match literal_text(&binding.value) {
328            Some(value) => translated.push(EnvBindingContract {
329                name: binding.name,
330                value,
331            }),
332            None => untranslated.record(
333                format!(
334                    "an environment binding for `{}` whose value interpolates a parameter",
335                    binding.name
336                ),
337                "under the old spelling this variable's value was built from the values passed \
338                 to the command at run time; an exported value is plain document text now, with \
339                 no parameters in scope",
340            ),
341        }
342    }
343    if let Some(path) = hardened_path {
344        // `hardening path` replaced the child's PATH with the declared value;
345        // a literal PATH binding does the same under the current executor,
346        // which clears the environment and honours declared bindings over its
347        // own PATH.
348        match literal_text(&path) {
349            Some(value) => translated.push(EnvBindingContract {
350                name: "PATH".to_owned(),
351                value,
352            }),
353            None => untranslated.record(
354                "an environment binding for `PATH` whose value interpolates a parameter".to_owned(),
355                "under the old spelling this command replaced the executable search path with a \
356                 value built from the values passed to it at run time; an exported value is \
357                 plain document text now, with no parameters in scope",
358            ),
359        }
360    }
361    translated
362}
363
364/// The prior-form constructs one command carries that the current form cannot
365/// express.
366struct Untranslated<'a> {
367    /// The command being read, for the operator's warning.
368    command: &'a str,
369    /// The FIRST construct found — one construct is enough to name the cure,
370    /// and the redeploy that cures one cures them all.
371    first: Option<String>,
372}
373
374impl Untranslated<'_> {
375    /// Record one untranslatable construct and tell the operator at OPEN, not
376    /// first at dispatch: a workflow that ran under the prior binary and would
377    /// refuse at three in the morning on a schedule is a fact the operator
378    /// hears at upgrade time. Every occurrence is named here; the marker keeps
379    /// the first, and rides every serialized rendering of this contract, so
380    /// any surface that shows the contract shows the state.
381    ///
382    /// `meant` says, in the operator's own terms, what the construct did under
383    /// the prior form — the sentence that turns "this no longer works" into
384    /// "here is what it used to do and why it cannot now".
385    fn record(&mut self, construct: String, meant: &str) {
386        tracing::warn!(
387            operation = "prior_form_command_read",
388            command = %self.command,
389            construct = %construct,
390            "this declared command was deployed before command declarations changed shape, and \
391             it carries {construct}: {meant}. The archive still opens and lists everywhere, but \
392             running this command will refuse until you redeploy the document under the current \
393             spelling"
394        );
395        if self.first.is_none() {
396            self.first = Some(construct);
397        }
398    }
399}
400
401/// The template's literal text, when it interpolates nothing.
402fn literal_text(template: &FillTemplate) -> Option<String> {
403    let mut text = String::new();
404    for piece in &template.pieces {
405        match piece {
406            FillPiece::Literal { text: literal } => text.push_str(literal),
407            FillPiece::Hole { .. } => return None,
408        }
409    }
410    Some(text)
411}