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}