Skip to main content

cargo_athena_core/
lib.rs

1//! cargo-athena runtime.
2//!
3//! Every `#[workflow]`/`#[container]` becomes a unit-struct **type** that
4//! implements [`Template`]. That type *is* the cross-crate wormhole: the
5//! type system resolves a callee's Argo name/inputs across modules and
6//! crates, and the generated `Template::collect` calls
7//! `<Callee as Template>::collect` directly — a monomorphic call, so the
8//! whole reachable closure is force-linked with no `inventory`/DCE games.
9//!
10//! Two worlds share one binary:
11//!
12//! * **Emit**: `main` calls `cargo_athena::entrypoint!(E)` (which calls
13//!   [`entrypoint_impl::<E>()`]); we walk the closure
14//!   from `E` and print one Argo `WorkflowTemplate` document per template
15//!   (cross-refs via `templateRef`) plus a runnable `Workflow` for `E`.
16//! * **Run** — Argo invokes the binary with `--cargo-athena-template <name>`;
17//!   we deserialize inputs, run the real container body, serialize outputs.
18//!
19//! `host!` declarations are still collected statically by the attribute
20//! macros and resolved through the `#[fragment]` (`inventory`) closure here
21//! — fragments are genuinely *called* by container bodies, so unlike
22//! templates they have a real symbol reference and no DCE concern.
23
24use std::collections::{HashMap, HashSet};
25
26/// Argo API types (generated from protobuf).
27pub use cargo_athena_api as api;
28// Re-exported so macro-generated code has stable paths under `::cargo_athena`.
29pub use inventory;
30pub use serde_json;
31// Maintained serde_yaml fork: YAML 1.1-aware emitter (quotes `n`/`yes`/
32// `null`/… so Argo's Go YAML→JSON parser can't mis-type them). serde_yaml
33// itself is archived/EOL.
34pub use serde_norway;
35
36/// Fan-out: `list.fan_out(|x| template(x, ..))` runs `template` once per
37/// element (Argo `withParam`); the binding is the aggregated `Vec<U>` of
38/// the per-element returns. This trait exists only so the ghost
39/// type-checks the element type, the closure, and the resulting
40/// `Vec<U>`; the macro lowers the call to Argo and it never runs.
41pub trait AthenaList<T> {
42    #[doc(hidden)]
43    fn fan_out<U, F: FnOnce(T) -> U>(self, _f: F) -> Vec<U>
44    where
45        Self: Sized,
46    {
47        unimplemented!("athena ghost: never executed")
48    }
49}
50impl<T> AthenaList<T> for Vec<T> {}
51impl<T, const N: usize> AthenaList<T> for [T; N] {}
52
53/// Error side of a `.continue_on(..)` binding.
54#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
55pub struct ArgoError {
56    /// Terminal Argo node phase (`"Failed"` / `"Error"`).
57    pub status: String,
58    /// Main-container exit code, when Argo reported one.
59    pub exit_code: Option<i32>,
60}
61
62/// Ghost-only: types a `.continue_on` binding as `Result`.
63#[doc(hidden)]
64pub fn __athena_fallible<T>(_: T) -> Result<T, ArgoError> {
65    unimplemented!("athena ghost: never executed")
66}
67
68/// Marker for types that may be injected into a `#[container]`
69/// attribute (`image = "repo:" + tag`). Restricted to `String`/`str`
70/// and the primitive numbers: their `serde_json` form, unwrapped at
71/// runtime by `{{=fromJSON(...)}}`, renders to the obvious raw scalar
72/// (`"v"`→`v`, `7`→`7`). A `Display` bound would wrongly admit types
73/// whose `Display` differs from their JSON round-trip. The macro emits
74/// a hidden `Injectable`-bounded assertion against the real arg type.
75#[doc(hidden)]
76pub trait Injectable {}
77macro_rules! __athena_injectable {
78    ($($t:ty),* $(,)?) => { $( impl Injectable for $t {} )* };
79}
80__athena_injectable!(
81    String, str, i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, f32, f64,
82);
83
84/// `host!("/lit/path")` — declare a hostPath volume for the enclosing
85/// container, evaluating to the (already-mounted) path at runtime.
86///
87/// Only valid inside a `#[cargo_athena::container]` or
88/// `#[cargo_athena::fragment]` fn: those attribute macros rewrite the
89/// invocations they can see into a literal `Path::new(...)` carrying
90/// the precomputed mount path. This `macro_rules!` form is what
91/// rust-analyzer / rustc see *before* the attribute macro expands — it
92/// fires `compile_error!` as the misuse gate AND keeps a type-correct
93/// stub expression so LSP hover / inlay hints / autocompletion on the
94/// return value all work at the call site even when the attribute
95/// macro hasn't expanded yet. The stub is never reached at runtime:
96/// inside `#[container]`/`#[fragment]` the attribute macro replaces
97/// this whole expression first; outside those, the `compile_error!`
98/// blocks the build before any code runs.
99#[macro_export]
100macro_rules! host {
101    ($path:literal) => {{
102        ::core::compile_error!(
103            "`host!` may only be used directly inside a \
104             `#[cargo_athena::container]` or `#[cargo_athena::fragment]` fn \
105             (not in a plain fn, a `#[workflow]`, or nested inside another \
106             macro invocation)"
107        );
108        ::std::path::Path::new($path)
109    }};
110    ($($t:tt)*) => {
111        ::core::compile_error!("`host!` takes a single string-literal path")
112    };
113}
114
115// None of the decl macros (`host!`, `load_artifact*!`, `save_artifact*!`,
116// `secret*!`) have a private declarative form. The `#[container]` /
117// `#[fragment]` attribute macros' `DeclRewrite` pass replaces every
118// recognized invocation at the expression level with a direct call to
119// the relevant `rt::*` helper, passing pre-baked strings (mount path,
120// artifact file path, env var name) computed once at proc-macro
121// expansion time. Invocations the attribute macro can't see — plain
122// fns, `#[workflow]` bodies, nested inside other macro invocations —
123// hit the public `compile_error!` gates below.
124
125/// Declare an Argo *input* artifact port and read it (bytes) at
126/// runtime. See the [`host!`] doc for the gate / LSP-stub pattern
127/// shared by every decl macro.
128#[macro_export]
129macro_rules! load_artifact {
130    ($name:literal) => {{
131        ::core::compile_error!(
132            "`load_artifact!` may only be used directly inside a \
133             `#[cargo_athena::container]` or `#[cargo_athena::fragment]` fn"
134        );
135        ::std::vec::Vec::<u8>::new()
136    }};
137    ($($t:tt)*) => {
138        ::core::compile_error!("load_artifact!(\"name\")")
139    };
140}
141
142/// Declare an Argo *input* artifact port and read it (UTF-8) at runtime.
143#[macro_export]
144macro_rules! load_artifact_str {
145    ($name:literal) => {{
146        ::core::compile_error!(
147            "`load_artifact_str!` may only be used directly inside a \
148             `#[cargo_athena::container]` or `#[cargo_athena::fragment]` fn"
149        );
150        ::std::string::String::new()
151    }};
152    ($($t:tt)*) => {
153        ::core::compile_error!("load_artifact_str!(\"name\")")
154    };
155}
156
157/// Declare an Argo *output* artifact port and write bytes to it at runtime.
158#[macro_export]
159macro_rules! save_artifact {
160    ($name:literal, $data:expr) => {{
161        ::core::compile_error!(
162            "`save_artifact!` may only be used directly inside a \
163             `#[cargo_athena::container]` or `#[cargo_athena::fragment]` fn"
164        );
165        // Force the data expression through `AsRef<[u8]>` so LSP
166        // catches bad-type passes at the call site (e.g. handing an
167        // `i32` to a bytes-port).
168        let _: &dyn ::core::convert::AsRef<[u8]> = &$data;
169    }};
170    ($($t:tt)*) => {
171        ::core::compile_error!("save_artifact!(\"name\", data)")
172    };
173}
174
175/// Declare an Argo *output* artifact port and write a string at runtime.
176#[macro_export]
177macro_rules! save_artifact_str {
178    ($name:literal, $data:expr) => {{
179        ::core::compile_error!(
180            "`save_artifact_str!` may only be used directly inside a \
181             `#[cargo_athena::container]` or `#[cargo_athena::fragment]` fn"
182        );
183        let _: &dyn ::core::convert::AsRef<str> = &$data;
184    }};
185    ($($t:tt)*) => {
186        ::core::compile_error!("save_artifact_str!(\"name\", data)")
187    };
188}
189
190/// Declare a K8s Secret-sourced env var and read it back at runtime as
191/// a `String`. Two literal args: `(secret_name, key)`. Panics at
192/// runtime if the env var the macro plants isn't set; pair with
193/// `secret_opt!` for the no-panic variant.
194#[macro_export]
195macro_rules! secret {
196    ($name:literal, $key:literal) => {{
197        ::core::compile_error!(
198            "`secret!` may only be used directly inside a \
199             `#[cargo_athena::container]` or `#[cargo_athena::fragment]` fn"
200        );
201        ::std::string::String::new()
202    }};
203    ($($t:tt)*) => {
204        ::core::compile_error!("secret!(\"secret-name\", \"key\")")
205    };
206}
207
208/// Mount a PVC of type `T` (a unit struct that implements
209/// [`Pvc`] via `#[ephemeral_pvc]` or `#[external_pvc]`) on this
210/// container's pod, and evaluate to the (already-mounted) path —
211/// `&'static Path`, picked deterministically from the type's
212/// [`Pvc::MOUNT_PATH`] so emit-side and in-pod always agree.
213///
214/// Same gating as [`host!`]: only valid inside a
215/// `#[cargo_athena::container]` or `#[cargo_athena::fragment]`. The
216/// `macro_rules!` form below emits `compile_error!` AND a
217/// type-correct stub so rust-analyzer can still infer `&'static Path`
218/// at the call site before the attribute macro expands.
219#[macro_export]
220macro_rules! pvc {
221    ($t:path) => {{
222        ::core::compile_error!(
223            "`pvc!` may only be used directly inside a \
224             `#[cargo_athena::container]` or `#[cargo_athena::fragment]` fn"
225        );
226        ::std::path::Path::new(<$t as $crate::Pvc>::MOUNT_PATH)
227    }};
228    ($($t:tt)*) => {
229        ::core::compile_error!("`pvc!` takes a single PVC type: pvc!(MyPvc)")
230    };
231}
232
233/// Same as [`secret!`] but returns `Option<String>` and emits
234/// `optional: true` on the Argo `secretKeyRef` — Argo skips the env
235/// entry instead of failing pod-start when the secret/key is missing.
236#[macro_export]
237macro_rules! secret_opt {
238    ($name:literal, $key:literal) => {{
239        ::core::compile_error!(
240            "`secret_opt!` may only be used directly inside a \
241             `#[cargo_athena::container]` or `#[cargo_athena::fragment]` fn"
242        );
243        ::core::option::Option::<::std::string::String>::None
244    }};
245    ($($t:tt)*) => {
246        ::core::compile_error!("secret_opt!(\"secret-name\", \"key\")")
247    };
248}
249
250/// Runtime shims referenced by the declaration macros. Artifact ports
251/// are plain files at fixed paths; Argo moves them (no S3 from us).
252///
253/// Every `&str` argument is **pre-baked at proc-macro expansion time**:
254/// the macros precompute the full artifact path / env var name from
255/// the user's literal and pass it as a string literal. So these
256/// helpers do no string-building of their own — just one I/O call plus
257/// a panic on failure. The original-name args are kept purely for the
258/// panic message context.
259pub mod rt {
260    // The host!, load_artifact!/_str, save_artifact!/_str, secret!,
261    // and secret_opt! macros all precompute their derived strings at
262    // expansion time and pass them through here. `super::host_mount
263    // _path` and `super::secret_env_name` remain the single sources of
264    // truth for the formulas (called once at emit time per template);
265    // their proc-macro mirrors are pinned by the algorithm tests.
266
267    // Where Argo drops/collects declared artifact ports inside the pod
268    // — emit-side formats Argo template artifact paths from these, and
269    // the proc macros bake them into `load_artifact!` / `save_artifact!`
270    // call sites. Single source of truth in `api::munge`; re-exported
271    // here so every existing `rt::IN_DIR` / `rt::OUT_DIR` caller keeps
272    // resolving.
273    pub use cargo_athena_api::munge::{ATHENA_IN_DIR as IN_DIR, ATHENA_OUT_DIR as OUT_DIR};
274
275    pub fn load_artifact(path: &str, name: &str) -> Vec<u8> {
276        std::fs::read(path)
277            .unwrap_or_else(|e| panic!("athena: load_artifact!({name:?}) {path}: {e}"))
278    }
279
280    pub fn load_artifact_str(path: &str, name: &str) -> String {
281        std::fs::read_to_string(path)
282            .unwrap_or_else(|e| panic!("athena: load_artifact_str!({name:?}) {path}: {e}"))
283    }
284
285    pub fn save_artifact(path: &str, name: &str, data: impl AsRef<[u8]>) {
286        if let Some(d) = std::path::Path::new(path).parent() {
287            std::fs::create_dir_all(d).expect("create artifact out dir");
288        }
289        std::fs::write(path, data.as_ref())
290            .unwrap_or_else(|e| panic!("athena: save_artifact!({name:?}) {path}: {e}"));
291    }
292
293    pub fn save_artifact_str(path: &str, name: &str, data: impl AsRef<str>) {
294        save_artifact(path, name, data.as_ref().as_bytes());
295    }
296
297    /// `secret!(name, key)` runtime: read the precomputed env var. The
298    /// macro passes `name`/`key` for the panic message context only;
299    /// the env var name itself was already munged at expand time.
300    pub fn secret_value(env_var: &str, name: &str, key: &str) -> String {
301        std::env::var(env_var).unwrap_or_else(|e| {
302            panic!("athena: secret!({name:?}, {key:?}) env var `{env_var}` missing: {e}")
303        })
304    }
305
306    /// `secret_opt!(name, key)` runtime: `None` when the env var is
307    /// unset (e.g. Argo skipped the entry because the secret/key is
308    /// missing and `optional: true`). Pure `std::env::var().ok()` —
309    /// kept here so the macros have a stable call target.
310    pub fn secret_value_opt(env_var: &str) -> Option<String> {
311        std::env::var(env_var).ok()
312    }
313}
314
315// Re-export the shared name/path/env-var derivers from `api::munge`.
316// Single source of truth — the proc-macro side imports them too.
317pub use crate::api::munge::secret_env_name;
318
319/// What kind of Argo template a type produces.
320#[derive(Clone, Copy, PartialEq, Eq, Debug)]
321pub enum TemplateKind {
322    /// Leaf — real code in a pod (`#[container]`).
323    Container,
324    /// Composition — a DAG of other templates (`#[workflow]`).
325    Workflow,
326}
327
328/// How a single Argo input or output flows: inline as a `parameter` (Argo
329/// stores it in workflow status, sized like a JSON parameter) or via S3
330/// as an `artifact` (DAG-wired via `outputs.artifacts` /
331/// `arguments.artifacts.from`). The `#[container]` and `#[workflow]`
332/// macros derive these per-slot from the function signature: a fn
333/// argument or return type of `cargo_athena::Artifact<T>` is `Artifact`,
334/// everything else is `Parameter`. Stamped into
335/// [`Template::INPUT_KINDS`] / [`Template::OUTPUT_KIND`] and read at
336/// emit-time by the workflow's per-task wiring (parallel to how
337/// [`Template::INPUTS`] is read for parameter names).
338#[derive(Clone, Copy, PartialEq, Eq, Debug)]
339pub enum IoKind {
340    Parameter,
341    Artifact,
342}
343
344/// A DAG-wired S3-backed value. Wrap a `#[container]` (or `#[workflow]`)
345/// return in `Artifact<T>` to flow the value via Argo's native artifact
346/// passing (`outputs.artifacts.return` + `arguments.artifacts.from`)
347/// instead of the inline-parameter path (`outputs.parameters.return`).
348/// Lifts the parameter-size ceiling and is the natural shape for
349/// binary/large payloads. A consumer accepting `Artifact<T>` reads the
350/// same value on the other side; the wire is the user's serialized `T`
351/// (JSON, tar+gzip'd by Argo on the producer and untarred on the
352/// consumer, transparent to user code).
353///
354/// The inner `T` is private by design: no field-access (`a.field`) on
355/// an `Artifact<T>` binding, no `Deref<Target=T>`, no `AthenaList<_>`
356/// impl. Those constraints fall out of Rust's own type rules in the
357/// `#[workflow]` ghost (see `feedback-ghost-first.md` in agent
358/// memory) — they are NOT enforced by bespoke macro checks. The only
359/// public surface is `Artifact::new` / `Artifact::into_inner`.
360#[derive(Debug)]
361pub struct Artifact<T> {
362    inner: T,
363}
364
365impl<T> Artifact<T> {
366    pub fn new(v: T) -> Self {
367        Self { inner: v }
368    }
369
370    pub fn into_inner(self) -> T {
371        self.inner
372    }
373}
374
375// `.clone()` in a `#[workflow]` body is the fan-out marker that lets
376// the same producer feed multiple consumers (see WORKFLOW.md). The
377// ghost type-checks it as a real `Clone` call so the binding has to
378// implement `Clone`; the macro lowers it as "reference the same
379// upstream task again", so no runtime clone happens in-pod.
380impl<T: Clone> Clone for Artifact<T> {
381    fn clone(&self) -> Self {
382        Self::new(self.inner.clone())
383    }
384}
385
386// Serde-transparent: the wire form is plain serialized `T`. The wrapper
387// is purely a Rust type marker that the macros key on; on disk it is
388// indistinguishable from `T` itself. Lets `#[container]`'s `run()` body
389// write `serde_json::to_writer(File::create(...), &val)` after
390// `.into_inner()` without any wrapper bytes leaking through.
391impl<T: ::serde::Serialize> ::serde::Serialize for Artifact<T> {
392    fn serialize<S: ::serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
393        self.inner.serialize(s)
394    }
395}
396
397impl<'de, T: ::serde::Deserialize<'de>> ::serde::Deserialize<'de> for Artifact<T> {
398    fn deserialize<D: ::serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
399        T::deserialize(d).map(Self::new)
400    }
401}
402
403/// The cross-crate identity of a template, implemented by the unit struct
404/// the `#[workflow]`/`#[container]` macros generate.
405///
406/// Callers never name the Argo string; they reference the *type*
407/// (`<foo::ingest as Template>::ARGO_NAME`), so name/input resolution is
408/// done by the compiler — collision-proof across crates, and the reference
409/// itself force-links the defining crate.
410pub trait Template {
411    /// Globally-unique Argo resource name (`<crate>-<fn>` by default).
412    const ARGO_NAME: &'static str;
413    /// Declared input parameter names, in order.
414    const INPUTS: &'static [&'static str];
415    /// Stringified Rust types of [`Self::INPUTS`], same order — for
416    /// `emulate`'s pre-launch arg checking and the `ls`
417    /// listings. Emitted by `#[container]` and `#[workflow]`; defaulted
418    /// empty for synthetic/hand impls.
419    const INPUT_TYPES: &'static [&'static str] = &[];
420    /// Per-input I/O kind, parallel to [`Self::INPUTS`]. An empty slice
421    /// (the backwards-compat default) means "all parameters" — what
422    /// every template did before [`Artifact`] landed. The
423    /// `#[container]` / `#[workflow]` macros set entries to
424    /// [`IoKind::Artifact`] for any argument typed `Artifact<T>` and
425    /// [`IoKind::Parameter`] for everything else. The workflow's
426    /// per-task wiring reads this at emit-time to decide between
427    /// `arguments.parameters[].value` and `arguments.artifacts[].from`.
428    const INPUT_KINDS: &'static [IoKind] = &[];
429    /// I/O kind of the function's return value. Default
430    /// [`IoKind::Parameter`] keeps every existing template's
431    /// `outputs.parameters.return` emission byte-identical; macros set
432    /// this to [`IoKind::Artifact`] when the return type is
433    /// `Artifact<T>` so the template emits `outputs.artifacts.return`
434    /// (S3-backed) instead.
435    const OUTPUT_KIND: IoKind = IoKind::Parameter;
436    /// `true` for athena-synthesized templates (the `if`/`else`
437    /// wrapper + per-arm sub-workflows). They're an implementation
438    /// detail, so `ls` hides them unless `--include-synthetic`.
439    const SYNTHETIC: bool = false;
440    const KIND: TemplateKind;
441    /// Whole-workflow exit-handler template name, from
442    /// `#[workflow(on_exit_if_root=…)]` / `#[container(on_exit_if_root=…)]`.
443    /// `emit` puts it on this template's own `spec.hooks.exit`; Argo
444    /// fires exit hooks workflow-scoped, so it runs only when this
445    /// workflow is the one submitted (inert as a nested templateRef).
446    const ON_EXIT: Option<&'static str> = None;
447    /// Workflow-scoped TTL GC, from `#[…(ttl(..))]`. `build_templates`
448    /// puts it on this template's own `spec.ttlStrategy` (same per-WT
449    /// plumbing as `ON_EXIT`; never on synthetic `if` wrappers).
450    const TTL: ::core::option::Option<crate::api::TtlStrategy> = None;
451    /// Workflow-scoped pod GC strategy, from `#[…(pod_gc(strategy=..))]`.
452    /// `build_templates` puts it on this template's own `spec.podGC`.
453    const POD_GC: ::core::option::Option<&'static str> = None;
454    /// Root-only whole-workflow runtime cap (seconds), from
455    /// `#[…(active_deadline_if_root=..)]`. `build_templates` stamps it
456    /// on this template's own `spec.activeDeadlineSeconds` (same per-WT,
457    /// root-only plumbing as `TTL`/`POD_GC`). This is the *only* working
458    /// whole-workflow timeout: Argo applies neither `Template.timeout`
459    /// nor `Template.activeDeadlineSeconds` to dag/steps templates.
460    const ACTIVE_DEADLINE_IF_ROOT: ::core::option::Option<i64> = None;
461    /// Root-only `WorkflowSpec.nodeSelector`, from
462    /// `#[workflow(node_selector_if_root = { … })]`. Argo's pod-build
463    /// lookup is 3-tier: `tmpl.NodeSelector → boundary.NodeSelector →
464    /// wfSpec.NodeSelector`, so this is the only knob that lands on
465    /// EVERY pod in the run unless that pod (or its immediate enclosing
466    /// dag/steps) overrides it (verified from `workflow/controller/
467    /// workflowpod.go:928-958`). Same per-WT plumbing as
468    /// `TTL`/`POD_GC`/`ACTIVE_DEADLINE_IF_ROOT`. Literal pairs only —
469    /// workflow attrs have no injectable args (see `#[workflow(node
470    /// _selector)]` for the rationale).
471    const NODE_SELECTOR_IF_ROOT: &'static [(&'static str, &'static str)] = &[];
472    /// Root-only `WorkflowSpec.synchronization.mutexes`, from
473    /// `#[…(mutexes_if_root = [{ name = …, namespace = … }])]`. Each
474    /// entry is `(name, namespace)`, already lowered to its final YAML
475    /// string form (literal, or `{{=fromJSON(workflow.parameters[…])}}`
476    /// for injected operands). `namespace == ""` ⇒ skip the field
477    /// (Argo defaults to the workflow's own namespace per
478    /// `workflow/sync/lock_name.go:58-67`). Same per-WT, root-only
479    /// plumbing as `TTL` / `POD_GC` / `NODE_SELECTOR_IF_ROOT`; Argo's
480    /// sync manager keys on `<ns>/Mutex/<name>` globally so two
481    /// SEPARATE Workflow runs contend on the same name (empirically
482    /// verified on v4.0.5 2026-05-25, holder key `<ns>/<wf>`).
483    const MUTEXES_IF_ROOT: &'static [(&'static str, &'static str)] = &[];
484    /// Root-only `WorkflowSpec.Tolerations` from
485    /// `#[…(tolerations_if_root = [...])]`. Each entry is `(key,
486    /// operator, value, effect, toleration_seconds)`. Strings are
487    /// already lowered (literal verbatim, or
488    /// `{{=fromJSON(workflow.parameters[..])}}` for injected operands).
489    /// `toleration_seconds == 0` ⇒ Argo skip-serializes (treated as
490    /// "unset" by k8s, applies forever).
491    const TOLERATIONS_IF_ROOT: &'static [(
492        &'static str,
493        &'static str,
494        &'static str,
495        &'static str,
496        i64,
497    )] = &[];
498    /// Root-only `WorkflowSpec.Affinity` from
499    /// `#[…(affinity_if_root = "...")]` as an opaque YAML/JSON string.
500    /// Parsed at emit time; user owns the schema (athena does NOT
501    /// validate). None ⇒ skip.
502    const AFFINITY_IF_ROOT: ::core::option::Option<&'static str> = None;
503    /// Root-only `WorkflowSpec.PodSpecPatch`, from
504    /// `#[workflow(pod_spec_patch_if_root = "...")]`. Already lowered
505    /// to its final string form (literal verbatim, or with
506    /// `{{=fromJSON(workflow.parameters[..])}}` operands spliced in
507    /// for injected pieces). Same per-WT, root-only plumbing as
508    /// `NODE_SELECTOR_IF_ROOT`. None ⇒ skip (existing goldens
509    /// unaffected).
510    const POD_SPEC_PATCH_IF_ROOT: ::core::option::Option<&'static str> = None;
511    /// Root-only `WorkflowSpec.ImagePullSecrets` (Secret names) from
512    /// `#[…(image_pull_secrets_if_root = ["regcred", ...])]`. K8s/
513    /// Argo expose this only at workflow scope (no per-template
514    /// knob); per-container needs go through `pod_spec_patch`. Same
515    /// per-WT, root-only plumbing as `MUTEXES_IF_ROOT`.
516    const IMAGE_PULL_SECRETS_IF_ROOT: &'static [&'static str] = &[];
517    /// Root-only `WorkflowSpec.parallelism` (cap on total concurrent
518    /// pods in the run), from `#[workflow(parallelism_if_root = N)]`.
519    /// Same per-WT, root-only plumbing as the other `_IF_ROOT` consts.
520    /// Template-level `Template.parallelism` from
521    /// `#[workflow(parallelism = N)]` does NOT use this const — it's
522    /// stamped directly into the `api::Template` literal by `build()`.
523    const PARALLELISM_IF_ROOT: ::core::option::Option<i64> = None;
524
525    /// Build this template's inner Argo `template` object.
526    fn build(ctx: &BuildCtx) -> api::Template;
527
528    /// Run-mode body. Argv is the function's positional parameters in
529    /// `INPUTS` order, each JSON-encoded (string -> `"v"`, number/bool
530    /// bare). Returns the function's JSON-encoded return value, which
531    /// the entrypoint writes to `CARGO_ATHENA_OUTPUT`. Overridden by
532    /// `#[container]`; never called on a `#[workflow]`.
533    fn run(_argv: &[String]) -> String {
534        panic!(
535            "`{}` is not a #[container]; nothing to run",
536            Self::ARGO_NAME
537        )
538    }
539
540    /// Push self + the transitive callee closure into `out`. The macro
541    /// generates `<Callee as Template>::collect(out)` per callee, so the
542    /// whole reachable set is linked by direct calls.
543    fn collect(out: &mut Collector);
544}
545
546/// PVC lifecycle, exposed via [`Pvc::LIFECYCLE`].
547///
548/// * `Ephemeral` — athena emits a `WorkflowSpec.volumeClaimTemplates[]`
549///   entry for this PVC on the submitted root; Argo creates a fresh
550///   PVC at workflow start and deletes it at workflow end. The Pod
551///   `claimName` is the [`Pvc::ARGO_NAME`].
552/// * `External` — pre-existing PVC; athena emits nothing at the
553///   workflow-spec level, just per-pod `volumes` + `volumeMounts`. The
554///   Pod `claimName` is [`Pvc::CLAIM_NAME`] (the existing PVC's name
555///   in the workflow's namespace).
556#[derive(Clone, Copy, PartialEq, Eq, Debug)]
557pub enum PvcLifecycle {
558    Ephemeral,
559    External,
560}
561
562/// The cross-crate identity of a PVC, implemented by the unit struct
563/// `#[ephemeral_pvc]` / `#[external_pvc]` generates.
564///
565/// Same wormhole pattern as [`Template`]: callers reference the type
566/// (`<BuildCache as Pvc>::ARGO_NAME`), so name resolution is done by
567/// the compiler — collision-proof across crates, and the reference
568/// itself force-links the defining crate (incl. its `inventory::
569/// submit!` of [`PvcReg`], which the emit-side reads to materialize
570/// the full PVC spec by name).
571///
572/// Many of the consts below are lifecycle-conditional: `Ephemeral`
573/// uses `SIZE`/`ACCESS_MODES`/`STORAGE_CLASS_NAME`; `External` uses
574/// `CLAIM_NAME`/`READ_ONLY`. The attribute macros fill in only the
575/// fields meaningful for the chosen lifecycle and leave the rest at
576/// their `""`/empty defaults.
577pub trait Pvc {
578    /// Globally-unique Argo resource name (`<crate>-<type-kebab>` by
579    /// default). Identifies the PVC across the wormhole AND drives
580    /// the in-pod mount path hash ([`MOUNT_PATH`](Self::MOUNT_PATH))
581    /// so emit and run agree.
582    const ARGO_NAME: &'static str;
583    /// `Ephemeral` or `External`.
584    const LIFECYCLE: PvcLifecycle;
585    /// `/athena/pvcs/<fnv-hash-of-ARGO_NAME>`. Stable across emit and
586    /// run by construction (same hash both sides). Hidden from users:
587    /// `pvc!(Type)` returns `&'static Path` pointing here.
588    const MOUNT_PATH: &'static str;
589    /// `Ephemeral`: storage request like `"10Gi"`. `""` for external.
590    const SIZE: &'static str = "";
591    /// `Ephemeral`: K8s `accessModes`, e.g.
592    /// `&["ReadWriteOnce"]` or `&["ReadWriteMany"]`. `&[]` for external.
593    const ACCESS_MODES: &'static [&'static str] = &[];
594    /// `Ephemeral`: K8s `StorageClassName`. `""` = use cluster default.
595    const STORAGE_CLASS_NAME: &'static str = "";
596    /// `External`: the existing PVC's name in the workflow's
597    /// namespace. `""` for ephemeral (Argo creates with `ARGO_NAME`).
598    const CLAIM_NAME: &'static str = "";
599    /// `External`: mount this PVC read-only on every consumer. Bakes
600    /// into the volume source, so it's all-or-nothing per declaration.
601    /// `false` for ephemeral.
602    const READ_ONLY: bool = false;
603}
604
605/// Inventory-registered PVC spec, submitted by every `#[ephemeral_pvc]`
606/// and `#[external_pvc]`. [`BuildCtx::collect`] loads them all into a
607/// `argo_name → PvcReg` map so emit-side code can materialize the full
608/// PVC spec from just a name string (which is what fragment closures
609/// propagate).
610pub struct PvcReg {
611    pub argo_name: &'static str,
612    pub mount_path: &'static str,
613    pub lifecycle: PvcLifecycle,
614    pub size: &'static str,
615    pub access_modes: &'static [&'static str],
616    pub storage_class_name: &'static str,
617    pub claim_name: &'static str,
618    pub read_only: bool,
619}
620inventory::collect!(PvcReg);
621
622// PVC name / mount-path / volume-name derivers live in
623// `api::munge` — re-exported so existing callers
624// (`cargo_athena_core::pvc_mount_path` etc.) keep working.
625pub use crate::api::munge::{pvc_mount_path, pvc_volume_name};
626
627// ---- athena.toml ---------------------------------------------------------
628
629/// `athena.toml` — required by `cargo athena` at emit time. Mirrors the
630/// parts of Argo's S3 `ArtifactRepository` we inject, plus bootstrap config.
631#[derive(Debug, Clone, serde::Deserialize)]
632pub struct AthenaConfig {
633    pub artifact_repository: ArtifactRepository,
634    #[serde(default)]
635    pub bootstrap: Bootstrap,
636    #[serde(default)]
637    pub defaults: Defaults,
638}
639
640#[derive(Debug, Clone, serde::Deserialize)]
641pub struct Defaults {
642    /// Kubernetes ServiceAccount the workflow pods run as (so users bind
643    /// their own RBAC). Per-`#[container(service_account=...)]` overrides.
644    #[serde(default = "default_service_account")]
645    pub service_account: String,
646    /// Default cargo package the `cargo athena` subcommands drive (so
647    /// you don't repeat `--package`). The `--package`/`-p` flag wins.
648    #[serde(default)]
649    pub package: Option<String>,
650    /// Default cargo bin within that package (multi-bin crates need
651    /// it). The `--bin` flag wins.
652    #[serde(default)]
653    pub bin: Option<String>,
654    /// Default Kubernetes namespace for `cargo athena submit`. Precedence:
655    /// `-n/--namespace` → `$ARGO_NAMESPACE` → this → `"default"`.
656    #[serde(default)]
657    pub namespace: Option<String>,
658}
659
660impl Default for Defaults {
661    fn default() -> Self {
662        Self {
663            service_account: default_service_account(),
664            package: None,
665            bin: None,
666            namespace: None,
667        }
668    }
669}
670
671fn default_service_account() -> String {
672    "default".to_string()
673}
674
675/// Resolve a container template's ServiceAccount: the
676/// `#[container(service_account=...)]` override, else `[defaults]`.
677pub fn service_account(ctx: &BuildCtx, over: Option<&str>) -> String {
678    over.map(str::to_string)
679        .unwrap_or_else(|| ctx.config().defaults.service_account.clone())
680}
681
682#[derive(Debug, Clone, serde::Deserialize)]
683pub struct ArtifactRepository {
684    pub s3: S3Repo,
685}
686
687#[derive(Debug, Clone, serde::Deserialize)]
688pub struct S3Repo {
689    pub endpoint: String,
690    pub bucket: String,
691    #[serde(default)]
692    pub region: String,
693    #[serde(default)]
694    pub insecure: bool,
695    pub access_key_secret: SecretRef,
696    pub secret_key_secret: SecretRef,
697}
698
699#[derive(Debug, Clone, serde::Deserialize)]
700pub struct SecretRef {
701    pub name: String,
702    pub key: String,
703}
704
705#[derive(Debug, Clone, serde::Deserialize)]
706pub struct Bootstrap {
707    /// Fallback image when a `#[container]` doesn't set its own. Per-
708    /// container `image` always wins (arbitrary by design); this is just
709    /// the small default for containers that don't care.
710    #[serde(default = "default_image")]
711    pub default_image: String,
712    /// Cross-compile / `uname` target matrix.
713    #[serde(default = "default_targets")]
714    pub targets: Vec<String>,
715}
716
717impl Default for Bootstrap {
718    fn default() -> Self {
719        Self {
720            default_image: default_image(),
721            targets: default_targets(),
722        }
723    }
724}
725
726fn default_image() -> String {
727    "busybox:1.36-musl".to_string()
728}
729
730fn default_targets() -> Vec<String> {
731    vec![
732        "x86_64-unknown-linux-musl".to_string(),
733        "aarch64-unknown-linux-musl".to_string(),
734    ]
735}
736
737/// Shown when no `athena.toml` can be located anywhere.
738const CONFIG_NOT_FOUND: &str = "athena.toml not found. Run from your workflow \
739    crate (or a parent dir), set $ATHENA_CONFIG, pass --config <FILE>, or create \
740    a global config at ~/.config/cargo-athena/athena.toml (see `cargo athena init`).";
741
742impl AthenaConfig {
743    /// Resolve *which* `athena.toml` to load, in priority order:
744    ///   1. `flag` (the `--config` flag),
745    ///   2. `env_config` (`$ATHENA_CONFIG`),
746    ///   3. the nearest `athena.toml` walking up from `cwd` (repo/dev mode),
747    ///   4. `<xdg_dir>/athena.toml` (a global config, for source-free use).
748    ///
749    /// Pure: every input is passed in (no env / cwd reads of its own), so
750    /// the ordering is unit-testable. `flag` / `env_config` are returned
751    /// unconditionally (explicit intent; existence is checked at load);
752    /// the walk-up and global candidates are returned only if the file
753    /// exists. `xdg_dir` is supplied by the CLI (resolved via `etcetera`);
754    /// the in-binary [`load`](Self::load) passes `None` and relies on the
755    /// CLI having resolved + exported `ATHENA_CONFIG` first.
756    pub fn resolve_config_path(
757        flag: Option<&std::path::Path>,
758        env_config: Option<&std::path::Path>,
759        cwd: &std::path::Path,
760        xdg_dir: Option<&std::path::Path>,
761    ) -> Option<std::path::PathBuf> {
762        if let Some(p) = flag {
763            return Some(p.to_path_buf());
764        }
765        // A set-but-empty `$ATHENA_CONFIG` (`export ATHENA_CONFIG=`, a blank
766        // `ATHENA_CONFIG=` in CI) is treated as unset, so it falls through
767        // to walk-up / global rather than shadowing them and panicking on an
768        // empty path later. Empty would otherwise silently defeat the global
769        // fallback this resolver exists to provide.
770        if let Some(p) = env_config.filter(|p| !p.as_os_str().is_empty()) {
771            return Some(p.to_path_buf());
772        }
773        if let Some(p) = Self::find_upwards_from(cwd) {
774            return Some(p);
775        }
776        if let Some(dir) = xdg_dir {
777            let p = dir.join("athena.toml");
778            if p.is_file() {
779                return Some(p);
780            }
781        }
782        None
783    }
784
785    /// `ATHENA_CONFIG` override, else the nearest `athena.toml` walking up
786    /// from the cwd. Only ever called during emit — the in-pod binary
787    /// (run-mode) never needs `athena.toml`. The global (`~/.config`)
788    /// fallback lives in the CLI, which resolves the effective path and
789    /// exports `ATHENA_CONFIG` before this runs, so the same file loads
790    /// here whether invoked in-process or in the spawned user binary.
791    pub fn load() -> Self {
792        Self::try_load().unwrap_or_else(|e| panic!("{e}"))
793    }
794
795    /// Fallible variant of [`Self::load`] for CLI callers: a missing or
796    /// malformed `athena.toml` is a routine user mistake there, so it
797    /// deserves a clean error message, not a panic with a backtrace
798    /// hint. The panicking `load` stays for the in-binary emit path,
799    /// where fail-loud is the documented contract.
800    pub fn try_load() -> Result<Self, String> {
801        let env_config = std::env::var_os("ATHENA_CONFIG").map(std::path::PathBuf::from);
802        let cwd = std::env::current_dir().unwrap_or_default();
803        let path = Self::resolve_config_path(None, env_config.as_deref(), &cwd, None)
804            .ok_or_else(|| CONFIG_NOT_FOUND.to_string())?;
805        let text =
806            std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
807        toml::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))
808    }
809
810    fn find_upwards_from(start: &std::path::Path) -> Option<std::path::PathBuf> {
811        let mut d = start.to_path_buf();
812        loop {
813            let p = d.join("athena.toml");
814            if p.is_file() {
815                return Some(p);
816            }
817            if !d.pop() {
818                return None;
819            }
820        }
821    }
822}
823
824/// Pod-scoped scratch root, backed by an `emptyDir` on every container
825/// template so all athena paths are writable regardless of the image
826/// (distroless / read-only rootfs) and shared with Argo's init/wait
827/// containers for artifact load/collect.
828pub const ATHENA_DIR: &str = "/athena";
829/// Where Argo's executor (init container) extracts the per-arch
830/// binaries from our `.tar.gz` input artifact. We rely on Argo's
831/// built-in tarball auto-extraction (no `archive: none`, no `tar` in
832/// the main container's image — see `container_delivery`).
833pub const ATHENA_BIN_DIR: &str = "/athena/bin";
834// In-pod roots for `host!` / `pvc!` mounts live in `api::munge`
835// (proc-macro and emit both reference them). Re-exported here so
836// every existing `cargo_athena_core::ATHENA_*_DIR` caller keeps
837// working.
838pub use crate::api::munge::{ATHENA_MOUNTS_DIR, ATHENA_PVCS_DIR};
839/// Where a *parameter-output* `#[container]` body writes its serialized
840/// return value (read by the template's
841/// `outputs.parameters.return.valueFrom.path`). The bootstrap exports
842/// this as `CARGO_ATHENA_OUTPUT` for parameter-output templates.
843pub const ATHENA_RESULT_FILE: &str = "/athena/result";
844/// Where an *artifact-output* `#[container]` body writes its serialized
845/// return value (read by the template's `outputs.artifacts.return.path`,
846/// then tar+gzip'd and uploaded by Argo's executor). The bootstrap
847/// exports this as `CARGO_ATHENA_OUTPUT` for artifact-output templates;
848/// the run-side body has one write site (`CARGO_ATHENA_OUTPUT`) and
849/// stays kind-agnostic.
850pub const ATHENA_RESULT_ARTIFACT_FILE: &str = "/athena/result-artifact";
851/// Where an `Artifact<T>`-typed input lands inside the container, one
852/// file per arg, named after the arg. The template declares
853/// `inputs.artifacts[].path = "/athena/in/<name>"`; the run-side body
854/// reads + deserializes from there.
855pub const ATHENA_INPUT_ARTIFACT_DIR: &str = "/athena/in";
856/// The in-pod arch-resolving + exec bootstrap, kept in a separate
857/// `bootstrap.sh` so it can be read, edited, and `shellcheck`'d as a
858/// plain shell file rather than buried in a Rust `format!`. `@@ARMS@@`
859/// / `@@BIN_DIR@@` / `@@OUTPUT_PATH@@` are substituted at emit time in
860/// `container_delivery`.
861const BOOTSTRAP_TEMPLATE: &str = include_str!("bootstrap.sh");
862/// Name of the scratch `emptyDir` volume.
863pub const SCRATCH_VOLUME: &str = "athena-work";
864/// Argo input-artifact name of the binary tarball `emit` injects.
865pub const ATHENA_DIST_ARTIFACT: &str = "athena-dist";
866/// Env var the in-pod entrypoint reads to pick which container template
867/// to run. Argv (positional, in `INPUTS` order) carries the function's
868/// own parameters.
869pub const CARGO_ATHENA_TEMPLATE_ENV: &str = "CARGO_ATHENA_TEMPLATE";
870/// Env var the in-pod entrypoint reads for where to write the body's
871/// serialized return value. Cross-file contract: `bootstrap.sh` exports
872/// it (`export CARGO_ATHENA_OUTPUT=@@OUTPUT_PATH@@`) before exec'ing
873/// the binary — keep the two in sync.
874pub const CARGO_ATHENA_OUTPUT_ENV: &str = "CARGO_ATHENA_OUTPUT";
875
876/// `true` if this binary is dispatching a container body (in-pod,
877/// started by Argo via the emitted bootstrap); `false` for every other
878/// mode (`cargo athena emit` / `ls` / `describe` / `submit`-emit-JSON).
879///
880/// Use this in `main()` to gate one-time setup you only want to fire
881/// in-pod -- a tracing/OTLP subscriber, a metrics exporter, anything
882/// that costs network or has side effects. Without the gate, those
883/// would also fire on every local `cargo athena emit` etc. that spawns
884/// the binary to introspect templates.
885///
886/// ```ignore
887/// fn main() {
888///     let _otel = cargo_athena::is_container_run().then(|| {
889///         tracing_subscriber::fmt().init();
890///         OtelFlushGuard::new()        // drops at end of main()
891///     });
892///     cargo_athena::entrypoint!(MyRoot);
893/// }
894/// ```
895pub fn is_container_run() -> bool {
896    std::env::var_os(CARGO_ATHENA_TEMPLATE_ENV).is_some()
897}
898
899/// Resolved S3 coordinates for one artifact (creds are supplied
900/// locally, e.g. via AWS env vars - `cargo athena emulate` uses
901/// `object_store`; the in-cluster path uses the k8s Secret refs).
902#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
903pub struct S3Ref {
904    pub endpoint: String,
905    pub bucket: String,
906    pub region: String,
907    pub insecure: bool,
908    pub key: String,
909}
910
911impl S3Ref {
912    /// A concrete object reference at `key` against the repo's bucket /
913    /// endpoint. The one place the `S3Repo` -> `S3Ref` field copy lives,
914    /// so `publish`, `prune`, and `doctor` can't drift on the mapping.
915    /// (Distinct from `S3Repo::s3_loc`, which targets Argo's
916    /// `api::S3Artifact` and also emits the secret-key selectors.)
917    pub fn from_repo(repo: &S3Repo, key: String) -> Self {
918        S3Ref {
919            endpoint: repo.endpoint.clone(),
920            bucket: repo.bucket.clone(),
921            region: repo.region.clone(),
922            insecure: repo.insecure,
923            key,
924        }
925    }
926}
927
928/// One artifact bound into the container at `path`, backed by S3.
929#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
930pub struct ArtifactRef {
931    pub s3: S3Ref,
932    pub path: String,
933}
934
935/// An input parameter and its stringified Rust type (`""` if unknown,
936/// e.g. synthetic templates). The position in the enclosing `params`
937/// vector is the positional argv slot the parameter occupies in-pod.
938#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
939pub struct ParamRef {
940    pub name: String,
941    pub ty: String,
942}
943
944/// Purpose-built introspection of one `#[container]`, derived from the
945/// *same* `Template::build()` `emit` uses (so it never drifts), but
946/// expressed in the runner's vocabulary instead of Argo's. Emitted as
947/// JSON by the binary when `CARGO_ATHENA_DESCRIBE=<name>` is set;
948/// consumed by `cargo athena emulate` to realize the spec under
949/// docker/podman locally, and by `cargo athena describe`.
950#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
951pub struct ContainerRunMeta {
952    /// Argo template name (`<crate>-<fn>`).
953    pub name: String,
954    /// Source crate (CARGO_PKG_NAME of the user binary). Used for the
955    /// `PACKAGE` column in `cargo athena ls`; an empty
956    /// string means the caller didn't fill it in.
957    #[serde(default)]
958    pub package: String,
959    /// `"container"`, `"workflow"`, or `"other"`.
960    pub kind: String,
961    /// athena-synthesized template (an `if`/`else` wrapper or arm) —
962    /// `ls` hides these unless `--include-synthetic`.
963    pub synthetic: bool,
964    /// Resolved container image.
965    pub image: String,
966    /// The injected bootstrap command + args, verbatim — run as-is so
967    /// the local execution path is byte-identical to the pod's.
968    pub command: Vec<String>,
969    pub args: Vec<String>,
970    /// Mount path of the pod-scoped scratch dir (the `emptyDir`, e.g.
971    /// `/athena`); bind a host temp dir here to read `result_path` back.
972    pub work_dir: String,
973    /// Input parameters and the env var each is delivered through.
974    pub params: Vec<ParamRef>,
975    /// The binary tarball artifact (always present for a container).
976    pub binary_artifact: Option<ArtifactRef>,
977    /// `load_artifact!` input ports (excludes the binary tarball).
978    pub input_artifacts: Vec<ArtifactRef>,
979    /// `save_artifact!` output ports.
980    pub output_artifacts: Vec<ArtifactRef>,
981    /// `host!` paths (mounted at the same path in-pod; bind 1:1 locally).
982    pub host_paths: Vec<String>,
983    /// File the body writes its serialized return to
984    /// (`outputs.parameters.return`); read it back from the bind mount.
985    pub result_path: Option<String>,
986}
987
988/// Wire-format version of the binary's metadata modes (PROBE / LIST /
989/// DESCRIBE / EMIT_JSON). The `cargo athena` CLI checks this on PROBE
990/// before trusting the other modes. Bump ONLY on a breaking change to
991/// those JSON shapes, NOT on every release, so an older CLI keeps
992/// working with a newer binary at the same protocol. Within one release
993/// line, a `ProbeInfo` struct mismatch at a matching protocol means the
994/// workflow binary's library and the CLI were built from different
995/// cargo-athena versions (a dev path-dependency skew) — `BinarySource::probe`
996/// names that precisely rather than failing an opaque deserialize.
997pub const ATHENA_PROTOCOL: u32 = 1;
998
999/// Fixed marker in a [`ProbeInfo`] response (the `kind` field). Lets a
1000/// consumer positively identify a cargo-athena binary and reject stray
1001/// stdout from a non-athena executable with a clear error, rather than an
1002/// opaque deserialization failure.
1003pub const ATHENA_PROBE_KIND: &str = "cargo_athena_probe";
1004
1005/// What a cargo-athena binary reports in `CARGO_ATHENA_PROBE` mode: a
1006/// handshake the CLI uses to (a) confirm the executable really is a
1007/// cargo-athena binary, (b) detect CLI/binary version skew, and (c)
1008/// learn the default/root template (so the workflow argument is optional).
1009/// Produced with NO `athena.toml` (no `BuildCtx::collect`): works
1010/// source-free / config-free.
1011#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
1012pub struct ProbeInfo {
1013    /// Always [`ATHENA_PROBE_KIND`]. A consumer checks this first to tell a
1014    /// real probe response apart from an unrelated binary's stdout.
1015    pub kind: String,
1016    /// Metadata wire-format version ([`ATHENA_PROTOCOL`]).
1017    pub athena_protocol: u32,
1018    /// cargo-athena toolchain version this binary was built against (for
1019    /// skew diagnostics; distinct from the user crate `version` below).
1020    pub athena_version: String,
1021    /// The binary's root template (`entrypoint!(Root)`): a `#[workflow]` for
1022    /// the typical DAG, or a `#[container]` for a single-container binary.
1023    /// The consumer command uses it as the default when none is named.
1024    pub default_template: String,
1025    /// User crate name (`CARGO_PKG_NAME`).
1026    pub package: String,
1027    /// User crate version (`CARGO_PKG_VERSION`); the release-channel
1028    /// fallback for `version_tag`.
1029    pub version: String,
1030    /// User binary name (`CARGO_BIN_NAME`).
1031    pub bin: String,
1032    /// The build-time-sealed version tag that names this binary's
1033    /// `WorkflowTemplate`s and its S3 key (`{pkg}/<tag>/{bin}.tar.gz`):
1034    /// the baked `ATHENA_VERSION_TAG`, else `kebab` of `version`. The CLI
1035    /// uses it for the `publish` upload key and the resolved-tag feedback
1036    /// line — never recomputed from local Cargo.toml/git.
1037    pub version_tag: String,
1038    /// `release` (clean + on the release branch at build) or `dev`
1039    /// (off-road build), derived from `version_tag`.
1040    pub channel: String,
1041}
1042
1043/// Drop the spaces `quote!` puts around `<` / `>` / `,` so a type
1044/// like `Vec < String >` round-trips as `Vec<String>` for human
1045/// display. `validate_args` already strips ALL whitespace; this is the
1046/// gentler variant for showing types verbatim (`Cow<'static, str>`
1047/// stays readable).
1048fn normalize_ty(s: &str) -> String {
1049    let mut out = String::with_capacity(s.len());
1050    let mut prev_open = false;
1051    for ch in s.chars() {
1052        match ch {
1053            '<' | '>' => {
1054                while out.ends_with(' ') {
1055                    out.pop();
1056                }
1057                out.push(ch);
1058                prev_open = ch == '<';
1059            }
1060            ',' => {
1061                while out.ends_with(' ') {
1062                    out.pop();
1063                }
1064                out.push(ch);
1065                prev_open = false;
1066            }
1067            ' ' if prev_open => {}
1068            ' ' if out.ends_with('<') || out.ends_with(',') => {}
1069            _ => {
1070                out.push(ch);
1071                prev_open = false;
1072            }
1073        }
1074    }
1075    out
1076}
1077
1078impl ContainerRunMeta {
1079    /// Derive the runner metadata from one built Argo template.
1080    /// `input_types` is parallel to the template's input parameters
1081    /// (same order); empty when unknown.
1082    fn from_template(t: &api::Template, input_types: &[&str]) -> Self {
1083        let kind = if t.container.is_some() {
1084            "container"
1085        } else if t.dag.is_some() || !t.steps.is_empty() {
1086            "workflow"
1087        } else {
1088            "other"
1089        };
1090        let c = t.container.as_ref();
1091        let mount_path = |vol: &str| {
1092            c.and_then(|c| {
1093                c.volume_mounts
1094                    .iter()
1095                    .find(|m| m.name == vol)
1096                    .map(|m| m.mount_path.clone())
1097            })
1098        };
1099        let to_ref = |a: &api::Artifact| {
1100            a.s3.as_ref().map(|s| ArtifactRef {
1101                s3: S3Ref {
1102                    endpoint: s.endpoint.clone(),
1103                    bucket: s.bucket.clone(),
1104                    region: s.region.clone(),
1105                    insecure: s.insecure,
1106                    key: s.key.clone(),
1107                },
1108                path: a.path.clone(),
1109            })
1110        };
1111        let in_arts = t.inputs.as_ref().map(|i| &i.artifacts);
1112        ContainerRunMeta {
1113            name: t.name.clone(),
1114            // Filled in by the caller from BuildCtx's artifact_key
1115            // (encoded as `{crate}/{version}/{bin}.tar.gz`).
1116            package: String::new(),
1117            kind: kind.to_string(),
1118            // set by the caller from the Collector (Template::SYNTHETIC
1119            // isn't visible through the type-erased builder fn here).
1120            synthetic: false,
1121            image: c.map(|c| c.image.clone()).unwrap_or_default(),
1122            command: c.map(|c| c.command.clone()).unwrap_or_default(),
1123            args: c.map(|c| c.args.clone()).unwrap_or_default(),
1124            work_dir: mount_path(SCRATCH_VOLUME).unwrap_or_else(|| ATHENA_DIR.to_string()),
1125            params: t
1126                .inputs
1127                .as_ref()
1128                .map(|i| {
1129                    i.parameters
1130                        .iter()
1131                        .enumerate()
1132                        .map(|(idx, p)| ParamRef {
1133                            name: p.name.clone(),
1134                            // `quote!(#ty).to_string()` spaces `<` and
1135                            // `>`, e.g. `Vec < String >`. Strip those
1136                            // (only inside generics) for display.
1137                            ty: input_types
1138                                .get(idx)
1139                                .map(|s| normalize_ty(s))
1140                                .unwrap_or_default(),
1141                        })
1142                        .collect()
1143                })
1144                .unwrap_or_default(),
1145            binary_artifact: in_arts
1146                .and_then(|a| a.iter().find(|a| a.name == ATHENA_DIST_ARTIFACT))
1147                .and_then(to_ref),
1148            input_artifacts: in_arts
1149                .map(|a| {
1150                    a.iter()
1151                        .filter(|a| a.name != ATHENA_DIST_ARTIFACT)
1152                        .filter_map(to_ref)
1153                        .collect()
1154                })
1155                .unwrap_or_default(),
1156            output_artifacts: t
1157                .outputs
1158                .as_ref()
1159                .map(|o| o.artifacts.iter().filter_map(to_ref).collect())
1160                .unwrap_or_default(),
1161            host_paths: t
1162                .volumes
1163                .iter()
1164                .filter_map(|v| v.host_path.as_ref().map(|h| h.path.clone()))
1165                .collect(),
1166            result_path: t.outputs.as_ref().and_then(|o| {
1167                o.parameters
1168                    .iter()
1169                    .find(|p| p.name == "return")
1170                    .and_then(|p| p.value_from.as_ref())
1171                    .map(|vf| vf.path.clone())
1172                    .filter(|p| !p.is_empty())
1173            }),
1174        }
1175    }
1176}
1177
1178/// What [`container_delivery`] produces for one `#[container]` template.
1179pub struct ContainerDelivery {
1180    /// Resolved image: the `#[container(image=...)]` override, else
1181    /// `[bootstrap].default_image`.
1182    pub image: String,
1183    pub command: Vec<String>,
1184    pub args: Vec<String>,
1185    pub artifact: api::Artifact,
1186}
1187
1188/// The arch-resolving bootstrap + the S3 binary artifact for one container
1189/// template. Called from macro-generated `Template::build` (emit only).
1190///
1191/// Runs inside the container's *arbitrary, user-chosen* image (it only
1192/// needs a POSIX `sh` and `uname` — **no `tar`**). The binary `.tar.gz`
1193/// is delivered as an Argo input artifact at [`ATHENA_BIN_DIR`]; with
1194/// the default `Archive` (no `archive: none`) Argo's executor init
1195/// container **auto-detects tarballs and untars them into the artifact
1196/// path** (proven from `workflow/executor/executor.go:262–289`,
1197/// `untar` ibid., real v4.0.5 source). So by the time our bootstrap
1198/// runs the per-arch `app-<triple>` files already exist under
1199/// [`ATHENA_BIN_DIR`]; the script just `uname`s, `chmod +x`'s, and
1200/// `exec`s — replacing the shell so the Rust binary is the container's
1201/// main process. Works whether the tarball has one entry or many.
1202///
1203/// The bootstrap forwards `"$@"` to the binary; the macro-generated
1204/// `Template::build` appends `--` then one positional argv slot per
1205/// fn parameter. Parameter args contribute `{{inputs.parameters.<n>}}`;
1206/// `#[inject("<expr>")]` args contribute the user's raw Argo expression
1207/// verbatim. The binary's runner reads all slots positionally in
1208/// declaration order. (We used to deliver each parameter via an
1209/// `ATHENA_PARAM_<n>` env var, but env vars are NOT eligible for
1210/// Argo's automatic large-args offload to a ConfigMap, so any large
1211/// parameter value would balloon the pod spec and could exceed the
1212/// `E2BIG` exec limit. Argo offloads `container.args` once the total
1213/// exceeds 128 KB.)
1214pub fn container_delivery(
1215    ctx: &BuildCtx,
1216    argv_elements: &[String],
1217    image_override: Option<&str>,
1218    output_kind: IoKind,
1219) -> ContainerDelivery {
1220    let cfg = ctx.config();
1221    let s3 = &cfg.artifact_repository.s3;
1222    let image = image_override
1223        .map(str::to_string)
1224        .unwrap_or_else(|| cfg.bootstrap.default_image.clone());
1225
1226    let mut arms = String::new();
1227    for triple in &cfg.bootstrap.targets {
1228        let arch = triple.split('-').next().unwrap_or(triple);
1229        let pat = if arch == "aarch64" {
1230            "aarch64|arm64"
1231        } else {
1232            arch
1233        };
1234        arms.push_str(&format!("  {pat}) __t={triple} ;;\n"));
1235    }
1236
1237    // The body's serialized return value lands at `CARGO_ATHENA_OUTPUT`.
1238    // Parameter-output containers route it to ATHENA_RESULT_FILE (which
1239    // the template's `outputs.parameters.return.valueFrom.path` reads).
1240    // Artifact-output containers route it to ATHENA_RESULT_ARTIFACT_FILE
1241    // (which the template's `outputs.artifacts.return.path` reads;
1242    // Argo's executor then tar+gzips and uploads to S3). The bootstrap
1243    // exports the right path before exec-ing the binary; the run-side
1244    // body has one write site (`CARGO_ATHENA_OUTPUT`) and stays kind-
1245    // agnostic.
1246    let output_path = match output_kind {
1247        IoKind::Parameter => ATHENA_RESULT_FILE,
1248        IoKind::Artifact => ATHENA_RESULT_ARTIFACT_FILE,
1249    };
1250
1251    // Argo's executor (init container) auto-extracts the `.tar.gz` into
1252    // ATHENA_BIN_DIR, so the bootstrap just picks + execs. The template
1253    // lives in a sibling `bootstrap.sh` file (legible / greppable /
1254    // shellcheck-able); we just substitute the @@-delimited slots.
1255    let script = BOOTSTRAP_TEMPLATE
1256        .replace("@@ARMS@@", &arms)
1257        .replace("@@BIN_DIR@@", ATHENA_BIN_DIR)
1258        .replace("@@OUTPUT_PATH@@", output_path);
1259
1260    // `sh -c "<script>" -- arg1 arg2 ...` puts "--" in $0 (placeholder)
1261    // and arg1/arg2 in "$@", which the bootstrap forwards to the binary.
1262    // Each element of `argv_elements` is a pre-formed positional argv
1263    // slot: parameter args contribute `{{inputs.parameters.<name>}}`,
1264    // `#[inject(...)]` args contribute the user's raw Argo expression.
1265    // Order matches the fn's declaration order so the run-side decode
1266    // (positional `__argv[i]`) lines up.
1267    let mut args = vec![script, "--".to_string()];
1268    args.extend(argv_elements.iter().cloned());
1269
1270    // `archive: None` (NOT `archive: none`) lets Argo auto-detect the
1271    // input as a tarball and untar it into `path` (`ATHENA_BIN_DIR`).
1272    let artifact = api::Artifact {
1273        name: ATHENA_DIST_ARTIFACT.to_string(),
1274        path: ATHENA_BIN_DIR.to_string(),
1275        s3: Some(s3_loc(s3, ctx.artifact_key())),
1276        archive: None,
1277        mode: None,
1278        from: String::new(),
1279    };
1280
1281    ContainerDelivery {
1282        image,
1283        command: vec!["/bin/sh".to_string(), "-c".to_string()],
1284        args,
1285        artifact,
1286    }
1287}
1288
1289/// A `#[fragment]`: a plain helper carrying `host!` decls. Still
1290/// `inventory`-based — a container's real body actually *calls* its
1291/// fragments, so the symbol reference exists and DCE is not a concern.
1292pub struct FragmentReg {
1293    pub rust_name: &'static str,
1294    pub host_paths: &'static [&'static str],
1295    pub in_artifacts: &'static [&'static str],
1296    pub out_artifacts: &'static [&'static str],
1297    /// `(secret_name, key, optional)` triples from this fragment's
1298    /// `secret!`/`secret_opt!` declarations.
1299    pub secrets: &'static [(&'static str, &'static str, bool)],
1300    /// Argo names of `Pvc` types this fragment uses directly via
1301    /// `pvc!(Type)`. Container build-side resolves the transitive
1302    /// closure via [`BuildCtx::resolved_pvc_names`] (same callee
1303    /// walker as `resolved_host_paths`).
1304    pub pvc_argo_names: &'static [&'static str],
1305    pub callees: &'static [&'static str],
1306}
1307inventory::collect!(FragmentReg);
1308
1309/// Field-wise equality for duplicate-registration detection in
1310/// [`BuildCtx::collect`] — an identical re-registration (same crate
1311/// linked twice) is harmless, a same-name/different-content pair is a
1312/// silent-collision bug.
1313fn same_fragment(a: &FragmentReg, b: &FragmentReg) -> bool {
1314    a.host_paths == b.host_paths
1315        && a.in_artifacts == b.in_artifacts
1316        && a.out_artifacts == b.out_artifacts
1317        && a.secrets == b.secrets
1318        && a.pvc_argo_names == b.pvc_argo_names
1319        && a.callees == b.callees
1320}
1321
1322/// See [`same_fragment`].
1323fn same_pvc(a: &PvcReg, b: &PvcReg) -> bool {
1324    a.mount_path == b.mount_path
1325        && a.lifecycle == b.lifecycle
1326        && a.size == b.size
1327        && a.access_modes == b.access_modes
1328        && a.storage_class_name == b.storage_class_name
1329        && a.claim_name == b.claim_name
1330        && a.read_only == b.read_only
1331}
1332
1333/// Fragment registry snapshot, passed to container `build`s.
1334pub struct BuildCtx {
1335    fragments: HashMap<&'static str, &'static FragmentReg>,
1336    /// `argo_name → PvcReg` of every PVC type linked into this
1337    /// binary. Populated from `inventory::iter::<PvcReg>` at
1338    /// [`BuildCtx::collect`] time. Used by emit-side code to
1339    /// materialize a full PVC spec (size, access_modes, claim_name,
1340    /// …) from just an argo name — which is what fragment closures
1341    /// propagate through.
1342    pvcs: HashMap<&'static str, &'static PvcReg>,
1343    config: AthenaConfig,
1344    /// Fully-resolved S3 object key for this binary's tarball
1345    /// (`{crate}/<tag>/{bin}.tar.gz`, where `<tag>` is [`version_tag`]).
1346    /// Built once by [`entrypoint_impl`] from the user binary's own
1347    /// `CARGO_PKG_*` / `CARGO_BIN_NAME` env vars (captured at the bin's
1348    /// compile time by the `entrypoint!` macro) plus the baked tag.
1349    /// `cargo athena publish` uploads to the same key (read off the
1350    /// freshly-built binary), so the upload and the emitted YAML always
1351    /// agree by construction — and a dev binary never overwrites a
1352    /// release tarball.
1353    ///
1354    /// [`version_tag`]: BuildCtx::version_tag
1355    artifact_key: String,
1356    /// User crate name (`CARGO_PKG_NAME`). Stamped as
1357    /// `cargo.athena/pkg`.
1358    krate: String,
1359    /// User crate version (`CARGO_PKG_VERSION`). Stamped as
1360    /// `cargo.athena/version` (version of the user's code, distinct from
1361    /// `cargo.athena/toolchain` which is athena's own version).
1362    version: String,
1363    /// User binary name (`CARGO_BIN_NAME`). Stamped as `cargo.athena/bin`.
1364    bin: String,
1365    /// The resolved, build-time-sealed version tag (the baked
1366    /// `ATHENA_VERSION_TAG`, else `kebab` of `version`). The single
1367    /// coordinate appended to every WT name, used as the S3 key segment,
1368    /// and stamped as `cargo.athena/tag`. NOT recomputed from local
1369    /// Cargo.toml/git — it is whatever was compiled into the binary.
1370    version_tag: String,
1371    /// Baked git short-sha (`ATHENA_GIT_COMMIT`), `None` for a plain
1372    /// `cargo build`. Stamped as `cargo.athena/commit`.
1373    commit: Option<String>,
1374    /// Baked dirty-tree flag (`ATHENA_GIT_DIRTY`), `None` for a plain
1375    /// `cargo build`. Stamped as `cargo.athena/dirty`.
1376    dirty: Option<bool>,
1377}
1378
1379impl BuildCtx {
1380    /// Emit-only: gathers fragments AND loads `athena.toml`. Never called
1381    /// in run-mode, so the in-pod binary needs no `athena.toml`. The
1382    /// `version_tag`/`commit`/`dirty` come from the build-time-baked
1383    /// `option_env!` consts threaded through `entrypoint!` (all `None`
1384    /// for a plain `cargo build` → release tag = `kebab(version)`).
1385    pub fn collect(
1386        krate: &str,
1387        version: &str,
1388        bin: &str,
1389        version_tag: Option<&str>,
1390        commit: Option<&str>,
1391        dirty: Option<&str>,
1392    ) -> Self {
1393        // Both registries are keyed by a bare name (fragments by fn
1394        // ident, PVCs by argo name), so two same-named declarations in
1395        // different modules/crates would silently last-win — one
1396        // container would inherit the *other* declaration's mounts /
1397        // secrets / spec. Identical re-registrations (the same crate
1398        // linked in twice) are harmless and deduped; a same-name entry
1399        // with *different* contents is always a bug, so fail loud here
1400        // at emit rather than far away in-pod.
1401        let mut fragments: HashMap<&'static str, &'static FragmentReg> = HashMap::new();
1402        for f in inventory::iter::<FragmentReg> {
1403            if let Some(prev) = fragments.insert(f.rust_name, f)
1404                && !same_fragment(prev, f)
1405            {
1406                panic!(
1407                    "two different `#[fragment]` fns named `{}` are linked into this \
1408                     binary. Fragment propagation is keyed by the bare fn name, so \
1409                     they would silently collide — rename one of them.",
1410                    f.rust_name,
1411                );
1412            }
1413        }
1414        let mut pvcs: HashMap<&'static str, &'static PvcReg> = HashMap::new();
1415        for p in inventory::iter::<PvcReg> {
1416            if let Some(prev) = pvcs.insert(p.argo_name, p)
1417                && !same_pvc(prev, p)
1418            {
1419                panic!(
1420                    "two different PVC types share the Argo name {:?}. Rename one \
1421                     type or give one an explicit `#[ephemeral_pvc(name = \"…\")]` / \
1422                     `#[external_pvc(name = \"…\")]`.",
1423                    p.argo_name,
1424                );
1425            }
1426        }
1427        // Seal the version tag (baked value munged, else kebab(semver))
1428        // and build the S3 key from it. Both rules live in `api::munge`,
1429        // shared with the config-free probe path, so emit and probe can't
1430        // disagree on the sealed tag, and publish/prune can't drift on the
1431        // key layout.
1432        let version_tag = crate::api::munge::seal_tag(version_tag, version);
1433        Self {
1434            fragments,
1435            pvcs,
1436            config: AthenaConfig::load(),
1437            artifact_key: crate::api::munge::binary_key(krate, &version_tag, bin),
1438            krate: krate.to_string(),
1439            version: version.to_string(),
1440            bin: bin.to_string(),
1441            version_tag,
1442            commit: commit.map(str::to_string),
1443            dirty: dirty.map(|s| s == "true" || s == "1"),
1444        }
1445    }
1446
1447    pub fn config(&self) -> &AthenaConfig {
1448        &self.config
1449    }
1450
1451    /// The S3 object key of this binary's tarball,
1452    /// `{crate}/<version_tag>/{bin}.tar.gz`.
1453    pub fn artifact_key(&self) -> &str {
1454        &self.artifact_key
1455    }
1456
1457    /// The resolved, build-time-sealed version tag (kebab). Appended to
1458    /// every emitted `WorkflowTemplate` name, the S3 key segment, and the
1459    /// `cargo.athena/tag` label. Sealed in the binary — `emit`/`submit`
1460    /// read it, never recompute it from the local Cargo.toml/git.
1461    pub fn version_tag(&self) -> &str {
1462        &self.version_tag
1463    }
1464
1465    /// `<base>-<tag>` cluster-resource name. The tag overlay is purely a
1466    /// cluster-identity concern: in-pod dispatch + every
1467    /// `templateRef.template` stay on `base` (the versioning invariant).
1468    /// Fails loud at emit if the result would exceed DNS-1123's 63-char
1469    /// limit — the user shortens the base with `#[…(name = "…")]`.
1470    pub fn versioned_name(&self, base: &str) -> String {
1471        let n = format!("{base}-{}", self.version_tag);
1472        assert!(
1473            n.len() <= 63,
1474            "versioned WorkflowTemplate name {n:?} is {} chars, over the \
1475             DNS-1123 63-char limit (base {base:?} + tag {:?}); shorten the \
1476             template name with #[workflow(name = \"…\")] / \
1477             #[container(name = \"…\")]",
1478            n.len(),
1479            self.version_tag,
1480        );
1481        // Fail loud at emit (not at cluster apply) if the tag ever carried
1482        // a non-DNS-1123 char. The tag is munged at every entry point
1483        // (BuildCtx::collect, gitinfo), so this is belt-and-suspenders.
1484        assert!(
1485            !n.is_empty()
1486                && !n.starts_with('-')
1487                && !n.ends_with('-')
1488                && n.chars()
1489                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
1490            "versioned WorkflowTemplate name {n:?} is not DNS-1123-safe \
1491             (base {base:?} + tag {:?})",
1492            self.version_tag,
1493        );
1494        n
1495    }
1496
1497    /// `release` (clean build on the release branch) or `dev` (off-road),
1498    /// derived from the sealed tag via the shared `api::munge::channel_of`.
1499    fn channel(&self) -> &'static str {
1500        crate::api::munge::channel_of(&self.version_tag)
1501    }
1502
1503    /// Baseline provenance labels stamped onto every emitted
1504    /// `WorkflowTemplate` (and the `--with-workflow` runnable `Workflow`).
1505    /// All under the `cargo.athena/*` namespace - intentionally NOT in
1506    /// `app.kubernetes.io/*`, despite the convention's appeal. The shared
1507    /// namespace would clash with Helm-managed deploys (which stamp
1508    /// `managed-by: Helm`), ArgoCD's instance tracking, Backstage/IDP
1509    /// catalogs scraping it for service identity, etc. Argo Workflows
1510    /// itself made the same call: every Argo label lives under
1511    /// `workflows.argoproj.io/*`, with an explicit comment at
1512    /// `workflow/common/common.go:97` calling out that their `/component`
1513    /// is "intentionally similar to `app.kubernetes.io/component`" but
1514    /// staying in their own namespace.
1515    ///
1516    /// `cargo.athena/version` is the USER crate's version (matches
1517    /// reader intuition - "what version of this code emitted this WT?");
1518    /// athena's own toolchain version is `cargo.athena/toolchain` to
1519    /// avoid the ambiguity from PR #57's first cut.
1520    pub fn athena_labels(&self) -> std::collections::BTreeMap<String, String> {
1521        use crate::api::munge;
1522        let mut m = std::collections::BTreeMap::new();
1523        m.insert(munge::LABEL_PKG.to_string(), self.krate.clone());
1524        m.insert(munge::LABEL_VERSION.to_string(), self.version.clone());
1525        m.insert(munge::LABEL_BIN.to_string(), self.bin.clone());
1526        m.insert(
1527            munge::LABEL_TOOLCHAIN.to_string(),
1528            env!("CARGO_PKG_VERSION").to_string(),
1529        );
1530        // Build-time-sealed version coordinate + provenance. `tag` and
1531        // `channel` are always present (the tag falls back to
1532        // `kebab(version)` for a plain build); `commit`/`dirty` only when
1533        // `cargo athena build`/`publish` baked them. `tag` makes `cargo
1534        // athena prune` a pure label-selector delete; the rest describe
1535        // the *artifact*, not the deploy environment. Keys are the shared
1536        // `api::munge::LABEL_*` consts, so the prune selector can't drift.
1537        m.insert(munge::LABEL_TAG.to_string(), self.version_tag.clone());
1538        m.insert(munge::LABEL_CHANNEL.to_string(), self.channel().to_string());
1539        if let Some(c) = &self.commit {
1540            m.insert(munge::LABEL_COMMIT.to_string(), c.clone());
1541        }
1542        if let Some(d) = self.dirty {
1543            m.insert(munge::LABEL_DIRTY.to_string(), d.to_string());
1544        }
1545        m
1546    }
1547
1548    /// Own literal decls ∪ the transitive `#[fragment]` closure for one
1549    /// kind of declaration (deduped, stable order). `select` picks which
1550    /// `FragmentReg` slice to pull from a reached fragment.
1551    fn resolved(
1552        &self,
1553        own: &[&str],
1554        own_callees: &[&str],
1555        select: impl Fn(&FragmentReg) -> &'static [&'static str],
1556    ) -> Vec<String> {
1557        let mut out: Vec<String> = Vec::new();
1558        let mut seen: HashSet<String> = HashSet::new();
1559        let mut push = |p: &str, out: &mut Vec<String>| {
1560            if seen.insert(p.to_string()) {
1561                out.push(p.to_string());
1562            }
1563        };
1564        for p in own {
1565            push(p, &mut out);
1566        }
1567        let mut queue: Vec<&str> = own_callees.to_vec();
1568        let mut visited: HashSet<&str> = HashSet::new();
1569        while let Some(c) = queue.pop() {
1570            if !visited.insert(c) {
1571                continue;
1572            }
1573            if let Some(f) = self.fragments.get(c) {
1574                for p in select(f) {
1575                    push(p, &mut out);
1576                }
1577                queue.extend(f.callees.iter().copied());
1578            }
1579        }
1580        out
1581    }
1582
1583    /// hostPaths: own `host!`s ∪ fragment closure.
1584    pub fn resolved_host_paths(&self, own: &[&str], callees: &[&str]) -> Vec<String> {
1585        self.resolved(own, callees, |f| f.host_paths)
1586    }
1587
1588    /// Input artifact ports: own `load_artifact*!`s ∪ fragment closure.
1589    pub fn resolved_in_artifacts(&self, own: &[&str], callees: &[&str]) -> Vec<String> {
1590        self.resolved(own, callees, |f| f.in_artifacts)
1591    }
1592
1593    /// Output artifact ports: own `save_artifact*!`s ∪ fragment closure.
1594    pub fn resolved_out_artifacts(&self, own: &[&str], callees: &[&str]) -> Vec<String> {
1595        self.resolved(own, callees, |f| f.out_artifacts)
1596    }
1597
1598    /// PVCs referenced directly via `pvc!(Type)` ∪ the transitive
1599    /// closure through `#[fragment]` callees. Each entry is a
1600    /// [`PvcReg::argo_name`]; look up the full spec in
1601    /// [`BuildCtx::pvc`] (or just call [`Self::pvc_volumes`]).
1602    pub fn resolved_pvc_names(&self, own: &[&str], callees: &[&str]) -> Vec<String> {
1603        self.resolved(own, callees, |f| f.pvc_argo_names)
1604    }
1605
1606    /// Look up a PVC's full spec by argo name (`""` if unknown — the
1607    /// macros only ever pass names backed by an actual `Pvc` impl, so
1608    /// `None` here means a wormhole link bug).
1609    pub fn pvc(&self, argo_name: &str) -> Option<&'static PvcReg> {
1610        self.pvcs.get(argo_name).copied()
1611    }
1612
1613    /// `(volumes, volume_mounts)` for a resolved PVC name list (the
1614    /// output of [`Self::resolved_pvc_names`]). Each name produces
1615    /// one `Volume { persistent_volume_claim: { claim_name } }` plus
1616    /// one matching `VolumeMount` at the PVC's stable
1617    /// `/athena/pvcs/<hash>` mount path.
1618    ///
1619    /// For an `Ephemeral` PVC the volume's `claim_name` is the
1620    /// PVC's argo name itself — Argo creates the PVC under that name
1621    /// via the workflow spec's `volumeClaimTemplates`. For
1622    /// `External` PVCs the `claim_name` is the user-provided
1623    /// pre-existing PVC name.
1624    pub fn pvc_volumes(&self, names: &[String]) -> (Vec<api::Volume>, Vec<api::VolumeMount>) {
1625        let mut vols = Vec::new();
1626        let mut mounts = Vec::new();
1627        for n in names {
1628            // The macros only ever pass names backed by an actual `Pvc`
1629            // impl, so an unknown name is a wormhole link bug — emitting
1630            // a template *missing a mount* would fail far away in-pod
1631            // (the `pvc!` path wouldn't exist). Fail loud at emit.
1632            let Some(reg) = self.pvc(n) else {
1633                panic!(
1634                    "PVC {n:?} is not registered in this binary — \
1635                     `pvc!`/fragment wormhole link bug (please report it)"
1636                )
1637            };
1638            let vol_name = pvc_volume_name(reg.argo_name);
1639            let claim = match reg.lifecycle {
1640                PvcLifecycle::Ephemeral => reg.argo_name.to_string(),
1641                PvcLifecycle::External => reg.claim_name.to_string(),
1642            };
1643            vols.push(api::Volume {
1644                name: vol_name.clone(),
1645                persistent_volume_claim: Some(api::PersistentVolumeClaimVolumeSource {
1646                    claim_name: claim,
1647                    read_only: reg.read_only,
1648                }),
1649                ..Default::default()
1650            });
1651            mounts.push(api::VolumeMount {
1652                name: vol_name,
1653                mount_path: reg.mount_path.to_string(),
1654                read_only: reg.read_only,
1655            });
1656        }
1657        (vols, mounts)
1658    }
1659
1660    /// Env-var-sourced K8s secrets: own `secret!`/`secret_opt!` decls
1661    /// ∪ the `#[fragment]` closure (deduped on the `(name, key)` pair).
1662    /// Same shape as `resolved`, but the data are triples not strings,
1663    /// so this is open-coded rather than going through the generic
1664    /// helper. Two extra rules the string kinds don't need:
1665    ///
1666    /// * **Required wins.** A pair declared by both `secret!` and
1667    ///   `secret_opt!` (traversal order is non-obvious to users) emits
1668    ///   `optional: false` — a missing secret then fails pod-start with
1669    ///   a clear K8s event instead of panicking mid-body in-pod.
1670    /// * **Env-name collisions fail loud.** `secret_env_name` flattens
1671    ///   `.`/`-`/`_` alike, so distinct pairs like `("a.b", "k")` and
1672    ///   `("a-b", "k")` map to one env var; emitting both would let one
1673    ///   silently shadow the other (host mounts are immune — they key
1674    ///   by hash). Panic at emit instead.
1675    pub fn resolved_secrets(
1676        &self,
1677        own: &[(&str, &str, bool)],
1678        own_callees: &[&str],
1679    ) -> Vec<(String, String, bool)> {
1680        let mut out: Vec<(String, String, bool)> = Vec::new();
1681        let mut idx: HashMap<(String, String), usize> = HashMap::new();
1682        let mut by_env: HashMap<String, (String, String)> = HashMap::new();
1683        let mut push = |n: &str, k: &str, opt: bool, out: &mut Vec<(String, String, bool)>| {
1684            let pair = (n.to_string(), k.to_string());
1685            if let Some(&i) = idx.get(&pair) {
1686                out[i].2 &= opt; // required wins over optional
1687                return;
1688            }
1689            let env = crate::api::munge::secret_env_name(n, k);
1690            if let Some((pn, pk)) = by_env.get(&env) {
1691                panic!(
1692                    "secrets ({pn:?}, {pk:?}) and ({n:?}, {k:?}) both map to the \
1693                     env var `{env}` — one would silently shadow the other in-pod. \
1694                     Rename one secret (or key) so they stay distinguishable."
1695                );
1696            }
1697            by_env.insert(env, pair.clone());
1698            idx.insert(pair, out.len());
1699            out.push((n.to_string(), k.to_string(), opt));
1700        };
1701        for (n, k, opt) in own {
1702            push(n, k, *opt, &mut out);
1703        }
1704        let mut queue: Vec<&str> = own_callees.to_vec();
1705        let mut visited: HashSet<&str> = HashSet::new();
1706        while let Some(c) = queue.pop() {
1707            if !visited.insert(c) {
1708                continue;
1709            }
1710            if let Some(f) = self.fragments.get(c) {
1711                for (n, k, opt) in f.secrets {
1712                    push(n, k, *opt, &mut out);
1713                }
1714                queue.extend(f.callees.iter().copied());
1715            }
1716        }
1717        out
1718    }
1719}
1720
1721fn archive_none() -> api::ArchiveStrategy {
1722    api::ArchiveStrategy {
1723        none: Some(api::NoneStrategy {}),
1724    }
1725}
1726
1727/// Build an Argo S3 location (artifact-repository creds from `athena.toml`)
1728/// for an exact object `key`.
1729pub fn s3_loc(s3: &S3Repo, key: &str) -> api::S3Artifact {
1730    api::S3Artifact {
1731        endpoint: s3.endpoint.clone(),
1732        bucket: s3.bucket.clone(),
1733        region: s3.region.clone(),
1734        insecure: s3.insecure,
1735        key: key.to_string(),
1736        access_key_secret: Some(api::SecretKeySelector {
1737            name: s3.access_key_secret.name.clone(),
1738            key: s3.access_key_secret.key.clone(),
1739            ..Default::default()
1740        }),
1741        secret_key_secret: Some(api::SecretKeySelector {
1742            name: s3.secret_key_secret.name.clone(),
1743            key: s3.secret_key_secret.key.clone(),
1744            ..Default::default()
1745        }),
1746    }
1747}
1748
1749/// A valid Argo artifact identifier derived from an S3 key (which may
1750/// contain `/`, `.`). The key itself is preserved in `s3.key`.
1751fn artifact_ident(key: &str) -> String {
1752    let mut s: String = key
1753        .chars()
1754        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
1755        .collect();
1756    s = s.trim_matches('-').to_ascii_lowercase();
1757    if s.is_empty() {
1758        s.push('a');
1759    }
1760    s
1761}
1762
1763/// [`artifact_ident`] for each key, panicking when two distinct keys
1764/// flatten to the same ident: one template would declare two artifact
1765/// ports with the same name, which Argo rejects (or worse, last-wins).
1766/// The flattening is lossy (`a.b` and `a-b` → `a-b`), so this is the
1767/// fail-loud guard for it. The same key appearing twice is fine —
1768/// callers dedup on the exact key string.
1769fn artifact_idents_checked(keys: &[String]) -> Vec<String> {
1770    let mut by_ident: HashMap<String, String> = HashMap::new();
1771    keys.iter()
1772        .map(|k| {
1773            let id = artifact_ident(k);
1774            if let Some(prev) = by_ident.insert(id.clone(), k.clone())
1775                && prev != *k
1776            {
1777                panic!(
1778                    "artifact keys {prev:?} and {k:?} on one template both flatten \
1779                     to the Argo artifact name {id:?} — rename one so they stay \
1780                     distinguishable."
1781                );
1782            }
1783            id
1784        })
1785        .collect()
1786}
1787
1788/// `load_artifact!("key")` input ports: Argo pulls the exact S3 object
1789/// `key` from the configured repo into the pod (raw, `archive: none`).
1790pub fn artifact_inputs(ctx: &BuildCtx, keys: &[String]) -> Vec<api::Artifact> {
1791    let s3 = &ctx.config().artifact_repository.s3;
1792    let idents = artifact_idents_checked(keys);
1793    keys.iter()
1794        .zip(idents)
1795        .map(|(k, ident)| api::Artifact {
1796            name: ident,
1797            path: format!("{}/{k}", rt::IN_DIR),
1798            s3: Some(s3_loc(s3, k)),
1799            archive: Some(archive_none()),
1800            mode: None,
1801            from: String::new(),
1802        })
1803        .collect()
1804}
1805
1806/// `(key, operator, value, effect, toleration_seconds)` — the lowered
1807/// shape the macro produces for each toleration entry, threaded through
1808/// `Template::TOLERATIONS_IF_ROOT` and into `WorkflowSpec.Tolerations`
1809/// by `Collector::stamp_spec`.
1810pub type TolerationTuple = (String, String, String, String, i64);
1811
1812/// `save_artifact!("key")` output ports: Argo pushes the written file to
1813/// the exact S3 object `key` in the configured repo (raw, `archive: none`).
1814pub fn artifact_outputs(ctx: &BuildCtx, keys: &[String]) -> Vec<api::Artifact> {
1815    let s3 = &ctx.config().artifact_repository.s3;
1816    let idents = artifact_idents_checked(keys);
1817    keys.iter()
1818        .zip(idents)
1819        .map(|(k, ident)| api::Artifact {
1820            name: ident,
1821            path: format!("{}/{k}", rt::OUT_DIR),
1822            s3: Some(s3_loc(s3, k)),
1823            archive: Some(archive_none()),
1824            mode: None,
1825            from: String::new(),
1826        })
1827        .collect()
1828}
1829
1830/// Accumulates the reachable templates (as `WorkflowTemplate`s) and the
1831/// run-mode dispatch table while `Template::collect` walks the closure.
1832pub struct Collector {
1833    /// argo name → the registering type's identity (+ its rust path for
1834    /// diagnostics). A `HashMap` (not a `HashSet`) so `enter` can tell a
1835    /// harmless re-visit of the same template (diamond reachability)
1836    /// from two *different* fns colliding on one Argo name — the latter
1837    /// would silently emit one template and dispatch both call sites to
1838    /// whichever body registered, so it fails loud instead.
1839    seen: HashMap<String, (std::any::TypeId, &'static str)>,
1840    /// Deferred so `athena.toml` is read only at emit, never run-mode.
1841    builders: Vec<fn(&BuildCtx) -> api::Template>,
1842    runners: HashMap<String, fn(&[String]) -> String>,
1843    /// `<argo name> -> <on_exit handler argo name>` for *every* template
1844    /// with an `on_exit` (not just the root). Each WorkflowTemplate
1845    /// carries its own `spec.hooks.exit`; Argo only fires the hook of
1846    /// the workflow that is actually submitted (workflow-scoped), so
1847    /// submitting a sub-workflow's template directly runs its own hook.
1848    exits: HashMap<String, &'static str>,
1849    /// `<argo name> -> ttlStrategy` for every template with `ttl(..)`.
1850    /// Stamped onto that WorkflowTemplate's `spec.ttlStrategy` (same
1851    /// per-WT, workflow-scoped semantics as `exits`).
1852    ttl: HashMap<String, crate::api::TtlStrategy>,
1853    /// `<argo name> -> podGC strategy` for every template with
1854    /// `pod_gc(..)`. Stamped onto that WT's `spec.podGC`.
1855    pod_gc: HashMap<String, String>,
1856    /// `<argo name> -> activeDeadlineSeconds` for every template with
1857    /// `active_deadline_if_root(..)`. Stamped onto that WT's
1858    /// `spec.activeDeadlineSeconds` (same per-WT, root-only as `ttl`).
1859    active_deadline: HashMap<String, i64>,
1860    /// `<argo name> -> nodeSelector key-value pairs` for every template
1861    /// that declares `#[workflow(node_selector_if_root = …)]`. Stamped
1862    /// onto that WT's `spec.nodeSelector` (the only nodeSelector knob
1863    /// Argo cascades over every pod in the run, root-only).
1864    node_selector_if_root: HashMap<String, Vec<(String, String)>>,
1865    /// `<argo name> -> mutexes` for every template declaring
1866    /// `#[…(mutexes_if_root = […])]`. Each entry is `(name, namespace)`
1867    /// already lowered to the final YAML form (literal, or
1868    /// `{{=fromJSON(workflow.parameters[…])}}` for injected operands);
1869    /// `namespace == ""` means "skip the field" (defaults to the wf's
1870    /// own ns). Stamped onto that WT's `spec.synchronization.mutexes`.
1871    mutexes_if_root: HashMap<String, Vec<(String, String)>>,
1872    /// `<argo name> -> tolerations` for every template declaring
1873    /// `#[…(tolerations_if_root = [...])]`. Strings already lowered.
1874    /// Stamped onto that WT's `spec.tolerations`.
1875    tolerations_if_root: HashMap<String, Vec<TolerationTuple>>,
1876    /// `<argo name> -> affinity YAML string` for every template with
1877    /// `#[…(affinity_if_root = "...")]`. Parsed at emit time and
1878    /// stuffed into `spec.affinity` as a `serde_norway::Value`.
1879    affinity_if_root: HashMap<String, String>,
1880    /// `<argo name> -> pod-spec strategic-merge patch (string)` for
1881    /// every template with `#[workflow(pod_spec_patch_if_root = "...")]`.
1882    /// Already lowered to its final form (literal, or with
1883    /// `{{=fromJSON(workflow.parameters[..])}}` injection operands).
1884    /// Stamped onto that WT's `spec.podSpecPatch`.
1885    pod_spec_patch_if_root: HashMap<String, String>,
1886    /// `<argo name> -> Secret names` for every template declaring
1887    /// `#[…(image_pull_secrets_if_root = [...])]`. Stamped onto that
1888    /// WT's `spec.imagePullSecrets` as `[{name}]` k8s references.
1889    image_pull_secrets_if_root: HashMap<String, Vec<String>>,
1890    /// `<argo name> -> WorkflowSpec.parallelism` for every template
1891    /// declaring `#[workflow(parallelism_if_root = N)]`. Stamped onto
1892    /// that WT's `spec.parallelism`.
1893    parallelism_if_root: HashMap<String, i64>,
1894    /// `<argo name> -> stringified input types` (parallel to the
1895    /// template's INPUTS), for `emulate` arg type-checking.
1896    types: HashMap<String, &'static [&'static str]>,
1897    /// Argo names of athena-synthesized templates (`Template::SYNTHETIC`)
1898    /// so `ls` can hide them by default.
1899    synthetic: HashSet<String>,
1900}
1901
1902impl Default for Collector {
1903    fn default() -> Self {
1904        Self::new()
1905    }
1906}
1907
1908impl Collector {
1909    pub fn new() -> Self {
1910        Self {
1911            seen: HashMap::new(),
1912            builders: Vec::new(),
1913            runners: HashMap::new(),
1914            exits: HashMap::new(),
1915            ttl: HashMap::new(),
1916            pod_gc: HashMap::new(),
1917            active_deadline: HashMap::new(),
1918            node_selector_if_root: HashMap::new(),
1919            mutexes_if_root: HashMap::new(),
1920            tolerations_if_root: HashMap::new(),
1921            affinity_if_root: HashMap::new(),
1922            pod_spec_patch_if_root: HashMap::new(),
1923            image_pull_secrets_if_root: HashMap::new(),
1924            parallelism_if_root: HashMap::new(),
1925            types: HashMap::new(),
1926            synthetic: HashSet::new(),
1927        }
1928    }
1929
1930    /// Returns `false` if `T` was already collected (generated `collect`
1931    /// impls return early in that case — dedup + cycle guard).
1932    ///
1933    /// Panics when a *different* type already claimed `T::ARGO_NAME`:
1934    /// without the check, only the first registrant would emit a
1935    /// template and every call site would dispatch to it by name —
1936    /// silently running the wrong body. Reachable with two same-named
1937    /// fns in different modules/crates, or two identical explicit
1938    /// `name = "…"` overrides.
1939    pub fn enter<T: Template + 'static>(&mut self) -> bool {
1940        let id = std::any::TypeId::of::<T>();
1941        match self.seen.entry(T::ARGO_NAME.to_string()) {
1942            std::collections::hash_map::Entry::Occupied(e) => {
1943                let (prev_id, prev_ty) = *e.get();
1944                if prev_id != id {
1945                    panic!(
1946                        "duplicate Argo template name {:?}: `{}` and `{}` both map to \
1947                         it. Rename one fn, or give one an explicit \
1948                         `#[container(name = \"…\")]` / `#[workflow(name = \"…\")]`.",
1949                        T::ARGO_NAME,
1950                        prev_ty,
1951                        std::any::type_name::<T>(),
1952                    );
1953                }
1954                false
1955            }
1956            std::collections::hash_map::Entry::Vacant(v) => {
1957                v.insert((id, std::any::type_name::<T>()));
1958                true
1959            }
1960        }
1961    }
1962
1963    /// Register a template by type: its `build` fn plus, if it sets
1964    /// `on_exit_if_root`, its exit handler keyed by Argo name (so
1965    /// `emit` can put `spec.hooks.exit` on *that* WorkflowTemplate).
1966    pub fn add<T: Template>(&mut self) {
1967        self.builders.push(T::build);
1968        if let Some(handler) = T::ON_EXIT {
1969            self.exits.insert(T::ARGO_NAME.to_string(), handler);
1970        }
1971        if let Some(t) = T::TTL {
1972            self.ttl.insert(T::ARGO_NAME.to_string(), t);
1973        }
1974        if let Some(s) = T::POD_GC {
1975            self.pod_gc.insert(T::ARGO_NAME.to_string(), s.to_string());
1976        }
1977        if let Some(s) = T::ACTIVE_DEADLINE_IF_ROOT {
1978            self.active_deadline.insert(T::ARGO_NAME.to_string(), s);
1979        }
1980        if !T::NODE_SELECTOR_IF_ROOT.is_empty() {
1981            self.node_selector_if_root.insert(
1982                T::ARGO_NAME.to_string(),
1983                T::NODE_SELECTOR_IF_ROOT
1984                    .iter()
1985                    .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1986                    .collect(),
1987            );
1988        }
1989        if !T::MUTEXES_IF_ROOT.is_empty() {
1990            self.mutexes_if_root.insert(
1991                T::ARGO_NAME.to_string(),
1992                T::MUTEXES_IF_ROOT
1993                    .iter()
1994                    .map(|(n, ns)| ((*n).to_string(), (*ns).to_string()))
1995                    .collect(),
1996            );
1997        }
1998        if !T::TOLERATIONS_IF_ROOT.is_empty() {
1999            self.tolerations_if_root.insert(
2000                T::ARGO_NAME.to_string(),
2001                T::TOLERATIONS_IF_ROOT
2002                    .iter()
2003                    .map(|(k, op, v, eff, secs)| {
2004                        (
2005                            (*k).to_string(),
2006                            (*op).to_string(),
2007                            (*v).to_string(),
2008                            (*eff).to_string(),
2009                            *secs,
2010                        )
2011                    })
2012                    .collect(),
2013            );
2014        }
2015        if let Some(a) = T::AFFINITY_IF_ROOT {
2016            self.affinity_if_root
2017                .insert(T::ARGO_NAME.to_string(), a.to_string());
2018        }
2019        if let Some(p) = T::POD_SPEC_PATCH_IF_ROOT {
2020            self.pod_spec_patch_if_root
2021                .insert(T::ARGO_NAME.to_string(), p.to_string());
2022        }
2023        if !T::IMAGE_PULL_SECRETS_IF_ROOT.is_empty() {
2024            self.image_pull_secrets_if_root.insert(
2025                T::ARGO_NAME.to_string(),
2026                T::IMAGE_PULL_SECRETS_IF_ROOT
2027                    .iter()
2028                    .map(|n| (*n).to_string())
2029                    .collect(),
2030            );
2031        }
2032        if let Some(p) = T::PARALLELISM_IF_ROOT {
2033            self.parallelism_if_root.insert(T::ARGO_NAME.to_string(), p);
2034        }
2035        if !T::INPUT_TYPES.is_empty() {
2036            self.types.insert(T::ARGO_NAME.to_string(), T::INPUT_TYPES);
2037        }
2038        if T::SYNTHETIC {
2039            self.synthetic.insert(T::ARGO_NAME.to_string());
2040        }
2041    }
2042
2043    pub fn add_runner(&mut self, argo_name: &str, run: fn(&[String]) -> String) {
2044        self.runners.insert(argo_name.to_string(), run);
2045    }
2046
2047    /// The deterministic `WorkflowTemplate` set `emit` serializes —
2048    /// every reachable template, sorted, with each `on_exit_if_root`
2049    /// hook stamped on its own template. Shared by YAML emit and the
2050    /// `CARGO_ATHENA_EMIT_JSON` mode `cargo athena submit` consumes for
2051    /// its register/drift checks.
2052    pub fn build_templates(&self, ctx: &BuildCtx) -> Vec<api::WorkflowTemplate> {
2053        let labels = ctx.athena_labels();
2054        let mut tpls: Vec<api::WorkflowTemplate> = self
2055            .builders
2056            .iter()
2057            .map(|b| {
2058                let inner = b(ctx);
2059                let mut wt = wrap_workflow_template(inner.name.clone(), inner);
2060                if let Some(meta) = wt.metadata.as_mut() {
2061                    meta.labels
2062                        .extend(labels.iter().map(|(k, v)| (k.clone(), v.clone())));
2063                }
2064                wt
2065            })
2066            .collect();
2067        tpls.sort_by_key(name_of);
2068
2069        // Stamp the `*_if_root` family onto each declaring template's
2070        // own `spec` — `on_exit_if_root` becomes `spec.hooks.exit`
2071        // (`templateRef`; legacy `spec.onExit` name-string can't cross
2072        // the one-WT-per-template wormhole), `ttl_if_root`/
2073        // `pod_gc_if_root`/`active_deadline_if_root`/
2074        // `node_selector_if_root` land on their matching `spec` fields.
2075        // Argo fires/applies them workflow-scoped (only for the
2076        // SUBMITTED root), so per-WT stamping is the correct model: a
2077        // templateRef'd sub-workflow stays inert when nested but fires
2078        // on direct submission. Single source of truth lives in
2079        // `stamp_spec` — the runnable Workflow path in `emit` calls the
2080        // same method so the two sites can't drift.
2081        for t in tpls.iter_mut() {
2082            let name = name_of(t);
2083            if let Some(spec) = t.spec.as_mut() {
2084                self.stamp_spec(&name, spec, ctx);
2085                // Version overlay, applied AFTER stamp_spec so its
2086                // base-name keying above is untouched: append the
2087                // build-time tag to this WT's resource identity — every
2088                // `templateRef.name`. The `metadata.name` rename below
2089                // completes the pair. `entrypoint` / inner
2090                // `templates[].name` / `templateRef.template` stay base
2091                // (the versioning invariant), so in-pod dispatch and
2092                // Argo's within-WT template lookup are unaffected.
2093                version_spec_refs(spec, ctx);
2094            }
2095            if let Some(meta) = t.metadata.as_mut() {
2096                meta.name = ctx.versioned_name(&meta.name);
2097            }
2098        }
2099        tpls
2100    }
2101
2102    /// Apply every per-template spec-scoped attribute (`on_exit_if_root`,
2103    /// `ttl_if_root`, `pod_gc_if_root`, `active_deadline_if_root`,
2104    /// `node_selector_if_root`) for `name` onto `spec`. The single
2105    /// source of truth for the `*_if_root` family — called once per
2106    /// emitted WT in `build_templates`, and once for the convenience
2107    /// runnable Workflow's root in `emit`. Adding a new spec-scoped
2108    /// attribute means adding one map field, one populate line in
2109    /// `add::<T>()`, and one `if let Some` block here — both
2110    /// stamping sites pick it up automatically.
2111    ///
2112    /// Also overlays `spec.volume_claim_templates` from every
2113    /// `#[ephemeral_pvc]` reachable through inventory (binary-wide).
2114    /// Argo only honors the submitted root's spec, so over-inclusion
2115    /// on non-root WTs is inert — the bloat is the cost of a simple
2116    /// inventory iteration vs. tracking per-WT reachability. (See
2117    /// the followup item in the PVC PR notes for the precision
2118    /// improvement.)
2119    fn stamp_spec(&self, name: &str, spec: &mut api::WorkflowSpec, ctx: &BuildCtx) {
2120        if let Some(handler) = self.exits.get(name) {
2121            spec.hooks
2122                .insert("exit".to_string(), exit_hook_ref(handler));
2123        }
2124        if let Some(ttl) = self.ttl.get(name) {
2125            spec.ttl_strategy = Some(ttl.clone());
2126        }
2127        if let Some(s) = self.pod_gc.get(name) {
2128            spec.pod_gc = Some(api::PodGc {
2129                strategy: s.clone(),
2130            });
2131        }
2132        if let Some(s) = self.active_deadline.get(name) {
2133            spec.active_deadline_seconds = Some(*s);
2134        }
2135        if let Some(ns) = self.node_selector_if_root.get(name) {
2136            for (k, v) in ns {
2137                spec.node_selector.insert(k.clone(), v.clone());
2138            }
2139        }
2140        if let Some(mtx) = self.mutexes_if_root.get(name) {
2141            let sync = spec
2142                .synchronization
2143                .get_or_insert_with(api::Synchronization::default);
2144            for (mname, mns) in mtx {
2145                sync.mutexes.push(api::Mutex {
2146                    name: mname.clone(),
2147                    namespace: mns.clone(),
2148                });
2149            }
2150        }
2151        if let Some(tols) = self.tolerations_if_root.get(name) {
2152            for (k, op, v, eff, secs) in tols {
2153                spec.tolerations.push(api::Toleration {
2154                    key: k.clone(),
2155                    operator: op.clone(),
2156                    value: v.clone(),
2157                    effect: eff.clone(),
2158                    toleration_seconds: if *secs == 0 { None } else { Some(*secs) },
2159                });
2160            }
2161        }
2162        if let Some(s) = self.affinity_if_root.get(name) {
2163            spec.affinity = Some(
2164                serde_norway::from_str(s)
2165                    .unwrap_or_else(|e| panic!("affinity_if_root: invalid YAML/JSON: {e}")),
2166            );
2167        }
2168        if let Some(p) = self.pod_spec_patch_if_root.get(name) {
2169            spec.pod_spec_patch = Some(p.clone());
2170        }
2171        if let Some(ipss) = self.image_pull_secrets_if_root.get(name) {
2172            for n in ipss {
2173                spec.image_pull_secrets
2174                    .push(api::LocalObjectReference { name: n.clone() });
2175            }
2176        }
2177        if let Some(p) = self.parallelism_if_root.get(name) {
2178            spec.parallelism = Some(*p);
2179        }
2180        // Every `#[ephemeral_pvc]` linked into this binary becomes a
2181        // `spec.volume_claim_templates` entry on every emitted WT.
2182        // Argo creates each PVC at workflow start (with
2183        // `metadata.name = <argo-name>`, the same `claim_name` each
2184        // pod's `volumes[]` references) and deletes it at workflow
2185        // end.
2186        //
2187        // Caveat: this over-includes for multi-workflow binaries.
2188        // Submitting workflow A in a binary that also defines an
2189        // unrelated workflow B causes Argo to create B's PVCs too,
2190        // since both are linked into the same binary and stamped on
2191        // every WT. The simplest fix is keeping one workflow per
2192        // binary (the recommended layout). Per-WT precision is a
2193        // possible follow-up if this hurts in practice.
2194        //
2195        // Sorted by argo name for deterministic emit.
2196        let mut ephemeral: Vec<&PvcReg> = ctx
2197            .pvcs
2198            .values()
2199            .copied()
2200            .filter(|r| r.lifecycle == PvcLifecycle::Ephemeral)
2201            .collect();
2202        ephemeral.sort_by_key(|r| r.argo_name);
2203        for reg in ephemeral {
2204            let mut requests = std::collections::BTreeMap::new();
2205            requests.insert("storage".to_string(), reg.size.to_string());
2206            spec.volume_claim_templates
2207                .push(api::PersistentVolumeClaim {
2208                    metadata: Some(api::ObjectMeta {
2209                        name: reg.argo_name.to_string(),
2210                        ..Default::default()
2211                    }),
2212                    spec: Some(api::PersistentVolumeClaimSpec {
2213                        access_modes: reg.access_modes.iter().map(|s| s.to_string()).collect(),
2214                        resources: Some(api::VolumeResourceRequirements { requests }),
2215                        storage_class_name: reg.storage_class_name.to_string(),
2216                    }),
2217                });
2218        }
2219    }
2220
2221    /// Emit the multi-doc YAML stream: one `WorkflowTemplate` per
2222    /// reachable template. `with_workflow` appends a convenience
2223    /// runnable `Workflow` (`generateName`, `workflowTemplateRef` →
2224    /// root) — off by default: the deterministic, stable-named
2225    /// `WorkflowTemplate`s are the artifact you register/GitOps, and
2226    /// runs are triggered with `argo submit --from
2227    /// workflowtemplate/<root>`. The convenience Workflow is opt-in for
2228    /// quick demos / `kubectl create -f -`.
2229    pub fn emit<E: Template>(&self, ctx: &BuildCtx, with_workflow: bool) -> String {
2230        let tpls = self.build_templates(ctx);
2231        let mut docs: Vec<String> = tpls
2232            .iter()
2233            .map(|t| serde_norway::to_string(t).expect("WorkflowTemplate is serializable"))
2234            .collect();
2235
2236        if !with_workflow {
2237            return docs.join("---\n");
2238        }
2239
2240        // Start from a default + the two fields specific to the runnable
2241        // Workflow (`workflowTemplateRef → root`, default SA from
2242        // athena.toml), then `stamp_spec` overlays every `*_if_root`
2243        // attribute for the root — same path `build_templates` uses
2244        // per-WT, so the two stamping sites can never drift when a new
2245        // `*_if_root` attribute is added.
2246        let mut spec = api::WorkflowSpec {
2247            workflow_template_ref: Some(api::WorkflowTemplateRef {
2248                // Points at the versioned root WT resource.
2249                name: ctx.versioned_name(E::ARGO_NAME),
2250                cluster_scope: false,
2251            }),
2252            service_account_name: ctx.config().defaults.service_account.clone(),
2253            ..Default::default()
2254        };
2255        // stamp_spec keys on the BASE name (the `_if_root` maps) and may
2256        // add an exit-hook templateRef; version_spec_refs then bumps that
2257        // ref to the versioned handler WT. Same order as build_templates.
2258        self.stamp_spec(E::ARGO_NAME, &mut spec, ctx);
2259        version_spec_refs(&mut spec, ctx);
2260        let wf = api::Workflow {
2261            api_version: api::API_VERSION.to_string(),
2262            kind: api::KIND_WORKFLOW.to_string(),
2263            metadata: Some(api::ObjectMeta {
2264                // Versioned, to match the workflow_template_ref above and
2265                // `cargo athena submit`'s generateName (both use the
2266                // versioned root) — the two run-creation paths stay in step.
2267                generate_name: format!("{}-", ctx.versioned_name(E::ARGO_NAME)),
2268                labels: ctx.athena_labels(),
2269                ..Default::default()
2270            }),
2271            spec: Some(spec),
2272        };
2273        docs.push(serde_norway::to_string(&wf).expect("Workflow is serializable"));
2274        docs.join("---\n")
2275    }
2276}
2277
2278/// A `spec.hooks.exit` `LifecycleHook` referencing the named handler
2279/// template. The legacy `spec.onExit: <name>` (a name-string) can't
2280/// cross the one-WT-per-template wormhole on real Argo v4.0.5 — only
2281/// the structured `templateRef` form survives, hence this single
2282/// construction reused by every stamping site.
2283fn exit_hook_ref(handler: &str) -> api::LifecycleHook {
2284    api::LifecycleHook {
2285        template_ref: Some(api::TemplateRef {
2286            name: handler.to_string(),
2287            template: handler.to_string(),
2288            cluster_scope: false,
2289        }),
2290        ..Default::default()
2291    }
2292}
2293
2294fn name_of(t: &api::WorkflowTemplate) -> String {
2295    t.metadata
2296        .as_ref()
2297        .map(|m| m.name.clone())
2298        .unwrap_or_default()
2299}
2300
2301/// Append the build-time version tag to every cluster-resource name
2302/// REFERENCE in `spec`: each `templateRef.name` on DAG tasks (and their
2303/// hooks), step tasks (and their hooks), and the workflow-scoped
2304/// `spec.hooks` (e.g. the `on_exit_if_root` handler). Deliberately NOT
2305/// `templateRef.template` — that's the callee's inner template name,
2306/// which stays on the base (Argo resolves it within the target WT, whose
2307/// single template keeps the base name). The tag is uniform (the one
2308/// root-binary version), so all refs get the same suffix and cross-WT
2309/// links stay consistent. The versioning analog of `Collector::stamp_spec`,
2310/// run per WT in `build_templates` and once for the `--with-workflow`
2311/// Workflow in `emit`.
2312fn version_spec_refs(spec: &mut api::WorkflowSpec, ctx: &BuildCtx) {
2313    fn bump(tr: &mut Option<api::TemplateRef>, ctx: &BuildCtx) {
2314        if let Some(r) = tr.as_mut() {
2315            r.name = ctx.versioned_name(&r.name);
2316        }
2317    }
2318    for t in spec.templates.iter_mut() {
2319        if let Some(dag) = t.dag.as_mut() {
2320            for task in dag.tasks.iter_mut() {
2321                bump(&mut task.template_ref, ctx);
2322                for h in task.hooks.values_mut() {
2323                    bump(&mut h.template_ref, ctx);
2324                }
2325            }
2326        }
2327        for group in t.steps.iter_mut() {
2328            for step in group.iter_mut() {
2329                bump(&mut step.template_ref, ctx);
2330                for h in step.hooks.values_mut() {
2331                    bump(&mut h.template_ref, ctx);
2332                }
2333            }
2334        }
2335    }
2336    for h in spec.hooks.values_mut() {
2337        bump(&mut h.template_ref, ctx);
2338    }
2339}
2340
2341/// Wrap one inner Argo `template` as a standalone `WorkflowTemplate` whose
2342/// resource name == the inner template name == its entrypoint.
2343pub fn wrap_workflow_template(name: String, inner: api::Template) -> api::WorkflowTemplate {
2344    api::WorkflowTemplate {
2345        api_version: api::API_VERSION.to_string(),
2346        kind: api::KIND_WORKFLOW_TEMPLATE.to_string(),
2347        metadata: Some(api::ObjectMeta {
2348            name: name.clone(),
2349            ..Default::default()
2350        }),
2351        spec: Some(api::WorkflowSpec {
2352            entrypoint: name,
2353            templates: vec![inner],
2354            arguments: None,
2355            workflow_template_ref: None,
2356            ..Default::default()
2357        }),
2358    }
2359}
2360
2361// `kebab` / `host_mount_path` / `host_volume_name` live in
2362// `api::munge`. The proc-macro and emit-side both call into the
2363// single source. Re-exported here so existing
2364// `cargo_athena_core::kebab` / `host_mount_path` call sites keep
2365// resolving.
2366use crate::api::munge::host_volume_name as volume_name;
2367pub use crate::api::munge::{host_mount_path, kebab};
2368
2369/// `volumes` + `volumeMounts` for a set of hostPaths (from `host!`).
2370/// Each mounts at [`host_mount_path`] (`/athena/mounts/<munged>`),
2371/// NOT at the host's own path — safe-by-construction.
2372pub fn host_path_volumes(paths: &[String]) -> (Vec<api::Volume>, Vec<api::VolumeMount>) {
2373    let mut vols = Vec::new();
2374    let mut mounts = Vec::new();
2375    for p in paths {
2376        let name = volume_name(p);
2377        vols.push(api::Volume {
2378            name: name.clone(),
2379            host_path: Some(api::HostPathVolumeSource {
2380                path: p.clone(),
2381                r#type: String::new(),
2382            }),
2383            ..Default::default()
2384        });
2385        mounts.push(api::VolumeMount {
2386            name,
2387            mount_path: host_mount_path(p),
2388            read_only: false,
2389        });
2390    }
2391    (vols, mounts)
2392}
2393
2394/// Every container template's volumes/mounts: the always-present
2395/// `emptyDir` scratch at [`ATHENA_DIR`] + the declared hostPaths. Two
2396/// hostPath sources:
2397///
2398/// - `host_paths` from `host!` — safe-by-construction, mounted at
2399///   [`host_mount_path`] (`/athena/mounts/<munged>`).
2400/// - `host_mounts` from `#[container(host_mount = [{…}])]` — explicit
2401///   `host_path` + `mount_path` + `read_only`, the user's escape hatch
2402///   for chosen mount paths (`/dev/shm`, sidecar data dirs, …).
2403///
2404/// If the same `host_path` appears in both, the `host_mount` entry
2405/// wins — same Volume, explicit `mount_path`/`read_only`. Keeps the
2406/// emit free of duplicate Volume names while preserving the user's
2407/// "I asked for it explicitly" intent.
2408pub fn container_volumes(
2409    host_paths: &[String],
2410    host_mounts: &[(String, String, bool)],
2411) -> (Vec<api::Volume>, Vec<api::VolumeMount>) {
2412    let mut vols = vec![api::Volume {
2413        name: SCRATCH_VOLUME.to_string(),
2414        empty_dir: Some(api::EmptyDirVolumeSource {}),
2415        ..Default::default()
2416    }];
2417    let mut mounts = vec![api::VolumeMount {
2418        name: SCRATCH_VOLUME.to_string(),
2419        mount_path: ATHENA_DIR.to_string(),
2420        read_only: false,
2421    }];
2422    // host_mount wins over host! on a shared host_path.
2423    let overridden: HashSet<&str> = host_mounts.iter().map(|(h, _, _)| h.as_str()).collect();
2424    for p in host_paths {
2425        if overridden.contains(p.as_str()) {
2426            continue;
2427        }
2428        let name = volume_name(p);
2429        vols.push(api::Volume {
2430            name: name.clone(),
2431            host_path: Some(api::HostPathVolumeSource {
2432                path: p.clone(),
2433                r#type: String::new(),
2434            }),
2435            ..Default::default()
2436        });
2437        mounts.push(api::VolumeMount {
2438            name,
2439            mount_path: host_mount_path(p),
2440            read_only: false,
2441        });
2442    }
2443    for (host_path, mount_path, read_only) in host_mounts {
2444        let name = volume_name(host_path);
2445        vols.push(api::Volume {
2446            name: name.clone(),
2447            host_path: Some(api::HostPathVolumeSource {
2448                path: host_path.clone(),
2449                r#type: String::new(),
2450            }),
2451            ..Default::default()
2452        });
2453        mounts.push(api::VolumeMount {
2454            name,
2455            mount_path: mount_path.clone(),
2456            read_only: *read_only,
2457        });
2458    }
2459    (vols, mounts)
2460}
2461
2462/// De-reference one argv slot if Argo's emissary rewrote it as a
2463/// `@/tmp/argo_arg_<i>.txt` sentinel.
2464///
2465/// When `c.Args` exceeds 128 KB the controller offloads the whole vector
2466/// to a `ConfigMap` and clears `c.Args`. The emissary then re-hydrates
2467/// the args from `$ARGO_CONTAINER_ARGS_FILE` and, separately, replaces
2468/// any single arg whose length still exceeds 128 KB with a sentinel
2469/// `@/tmp/argo_arg_<i>.txt` whose contents are the real value
2470/// (`cmd/argoexec/commands/emissary.go` PR #15265). The container is
2471/// expected to know that convention and read the file.
2472///
2473/// Gating on `$ARGO_CONTAINER_ARGS_FILE` (set by the controller only on
2474/// offload) keeps this safe: a Regime-B parameter value never starts
2475/// with `@` (string literals start with `"`, numbers with a digit/sign,
2476/// bools with `t`/`f`), so even if a user shell wires up a workflow
2477/// outside Argo we won't mis-interpret a literal `@`-prefixed argv.
2478fn deref_offloaded_arg(raw: String) -> String {
2479    if std::env::var_os("ARGO_CONTAINER_ARGS_FILE").is_none() {
2480        return raw;
2481    }
2482    let Some(path) = raw.strip_prefix('@') else {
2483        return raw;
2484    };
2485    if !path.starts_with("/tmp/argo_arg_") {
2486        return raw;
2487    }
2488    std::fs::read_to_string(path)
2489        .unwrap_or_else(|e| panic!("failed reading offloaded arg {path}: {e}"))
2490}
2491
2492/// The entrypoint a user's `main` calls, parameterised by the root
2493/// workflow type. Referencing `E` force-links the entire reachable
2494/// closure (each `collect` calls callees' `collect` directly).
2495///
2496/// `krate`/`version`/`bin` identify the *current binary* and are baked
2497/// into every emitted container's S3 artifact key
2498/// (`{krate}/{version}/{bin}.tar.gz`). They're captured at the user
2499/// binary's compile time by the `entrypoint!` macro (the facade
2500/// crate) from `CARGO_PKG_NAME`/`CARGO_PKG_VERSION`/`CARGO_BIN_NAME`,
2501/// so `cargo athena publish` (which derives the same key from
2502/// `cargo metadata` + `--bin`) and the emitted YAML always agree by
2503/// construction, with no `[artifact]` config field needed.
2504pub fn entrypoint_impl<E: Template>(
2505    krate: &str,
2506    version: &str,
2507    bin: &str,
2508    version_tag: Option<&str>,
2509    commit: Option<&str>,
2510    dirty: Option<&str>,
2511) {
2512    let mut collector = Collector::new();
2513    E::collect(&mut collector);
2514
2515    // Run-mode: `CARGO_ATHENA_TEMPLATE=<name>` selects which template's
2516    // body to run; the function's parameters arrive as positional argv
2517    // in INPUTS order (JSON-encoded). The selector lives in env so the
2518    // pod spec doesn't carry it as a per-template argv string, and so
2519    // argv is 100% function data, eligible for Argo's automatic offload
2520    // of large `container.args` to a ConfigMap (env vars are not).
2521    if let Ok(t) = std::env::var(CARGO_ATHENA_TEMPLATE_ENV) {
2522        let run = *collector
2523            .runners
2524            .get(&t)
2525            .unwrap_or_else(|| panic!("no runnable container template named {t:?}"));
2526        let argv: Vec<String> = std::env::args().skip(1).map(deref_offloaded_arg).collect();
2527        let output = run(&argv);
2528        if let Ok(path) = std::env::var(CARGO_ATHENA_OUTPUT_ENV) {
2529            std::fs::write(path, &output).expect("write CARGO_ATHENA_OUTPUT");
2530        } else {
2531            println!("{output}");
2532        }
2533        return;
2534    }
2535
2536    // `cargo athena` consumer commands run this FIRST to confirm the
2537    // binary is a cargo-athena binary and to agree on the metadata
2538    // wire-format version before trusting LIST / DESCRIBE / EMIT_JSON.
2539    // Deliberately needs NO `athena.toml` (no `BuildCtx::collect`) so it
2540    // works source-free, and it reports the root template the consumer
2541    // command defaults to when none is named.
2542    if std::env::var_os("CARGO_ATHENA_PROBE").is_some() {
2543        // Resolve the sealed tag + channel without a BuildCtx (probe is
2544        // config-free), via the SAME `api::munge` helpers `BuildCtx::collect`
2545        // uses, so probe and emit can't disagree on either.
2546        let tag = crate::api::munge::seal_tag(version_tag, version);
2547        let channel = crate::api::munge::channel_of(&tag);
2548        let info = ProbeInfo {
2549            kind: ATHENA_PROBE_KIND.to_string(),
2550            athena_protocol: ATHENA_PROTOCOL,
2551            athena_version: env!("CARGO_PKG_VERSION").to_string(),
2552            default_template: E::ARGO_NAME.to_string(),
2553            package: krate.to_string(),
2554            version: version.to_string(),
2555            bin: bin.to_string(),
2556            version_tag: tag,
2557            channel: channel.to_string(),
2558        };
2559        println!(
2560            "{}",
2561            serde_json::to_string(&info).expect("ProbeInfo is serializable")
2562        );
2563        return;
2564    }
2565
2566    // `cargo athena ls` sets this to enumerate every reachable
2567    // template's metadata as a JSON array (same per-template derivation
2568    // as describe — so names/params shown are exactly what runs).
2569    if std::env::var_os("CARGO_ATHENA_LIST").is_some() {
2570        let ctx = BuildCtx::collect(krate, version, bin, version_tag, commit, dirty);
2571        let all: Vec<ContainerRunMeta> = collector
2572            .builders
2573            .iter()
2574            .map(|b| {
2575                let t = b(&ctx);
2576                let it = collector.types.get(&t.name).copied().unwrap_or(&[]);
2577                let mut m = ContainerRunMeta::from_template(&t, it);
2578                m.package = krate.to_string();
2579                m.synthetic = collector.synthetic.contains(&t.name);
2580                m
2581            })
2582            .collect();
2583        println!(
2584            "{}",
2585            serde_json::to_string(&all).expect("ContainerRunMeta is serializable")
2586        );
2587        return;
2588    }
2589
2590    // `cargo athena emulate/describe` sets this to fetch ONE
2591    // template's metadata as JSON (it then realizes that exact spec
2592    // locally via docker/podman). Reusing `Template::build` here is what
2593    // makes the local run identical to Argo by construction — same
2594    // image, bootstrap, env, volumes, and artifacts as `emit`.
2595    if let Ok(name) = std::env::var("CARGO_ATHENA_DESCRIBE") {
2596        let ctx = BuildCtx::collect(krate, version, bin, version_tag, commit, dirty);
2597        // The default emitted name is `<crate>-<fn>`; the CLI already
2598        // shows package + short name as separate columns, so accept
2599        // either the full name or the short form (and fall back to the
2600        // full name in error messages so the user sees what we tried).
2601        let full = format!("{krate}-{name}");
2602        let tpl = collector
2603            .builders
2604            .iter()
2605            .map(|b| b(&ctx))
2606            .find(|t| t.name == name || t.name == full)
2607            .unwrap_or_else(|| panic!("no template named {name:?} (or {full:?})"));
2608        let resolved = tpl.name.clone();
2609        let input_types = collector.types.get(&resolved).copied().unwrap_or(&[]);
2610        let mut meta = ContainerRunMeta::from_template(&tpl, input_types);
2611        meta.package = krate.to_string();
2612        meta.synthetic = collector.synthetic.contains(&resolved);
2613        println!(
2614            "{}",
2615            serde_json::to_string(&meta).expect("ContainerRunMeta is serializable")
2616        );
2617        return;
2618    }
2619
2620    // `cargo athena submit` sets this to get the deterministic
2621    // `WorkflowTemplate` set as a JSON array (structured — for its
2622    // register-if-missing / drift-detect / apply checks), instead of
2623    // re-parsing the YAML `emit` prints.
2624    if std::env::var_os("CARGO_ATHENA_EMIT_JSON").is_some() {
2625        let ctx = BuildCtx::collect(krate, version, bin, version_tag, commit, dirty);
2626        println!(
2627            "{}",
2628            serde_json::to_string(&collector.build_templates(&ctx))
2629                .expect("WorkflowTemplate is serializable")
2630        );
2631        return;
2632    }
2633
2634    // `cargo athena emit --with-workflow` sets this on the child so the
2635    // convenience runnable Workflow is appended (default: templates
2636    // only — deterministic, `kubectl apply`-able, GitOps-clean).
2637    let with_workflow = std::env::var_os("CARGO_ATHENA_WITH_WORKFLOW").is_some_and(|v| v == "1");
2638    let ctx = BuildCtx::collect(krate, version, bin, version_tag, commit, dirty);
2639    print!("{}", collector.emit::<E>(&ctx, with_workflow));
2640}
2641
2642#[cfg(test)]
2643mod tests {
2644    use super::AthenaConfig;
2645    use std::path::{Path, PathBuf};
2646    use std::sync::atomic::{AtomicU32, Ordering};
2647
2648    // `resolve_config_path` is pure (takes its inputs as params, reads no
2649    // env / cwd of its own), so these exercise the precedence order
2650    // without mutating the process environment, safe under parallel test
2651    // runs. Filesystem-touching cases use a unique temp dir each.
2652    static SEQ: AtomicU32 = AtomicU32::new(0);
2653
2654    fn tmpdir(tag: &str) -> PathBuf {
2655        let n = SEQ.fetch_add(1, Ordering::Relaxed);
2656        let d =
2657            std::env::temp_dir().join(format!("athena-cfg-test-{}-{tag}-{n}", std::process::id()));
2658        std::fs::create_dir_all(&d).unwrap();
2659        d
2660    }
2661
2662    fn touch(dir: &Path, name: &str) -> PathBuf {
2663        let p = dir.join(name);
2664        std::fs::write(&p, "").unwrap();
2665        p
2666    }
2667
2668    // temp_dir may be a symlink (e.g. macOS /tmp -> /private/tmp); compare
2669    // canonicalized so the walk-up result matches the file we created.
2670    fn canon(p: PathBuf) -> PathBuf {
2671        std::fs::canonicalize(p).unwrap()
2672    }
2673
2674    #[test]
2675    fn flag_wins_over_everything() {
2676        let flag = PathBuf::from("/tmp/explicit-athena.toml");
2677        let env = PathBuf::from("/tmp/env-athena.toml");
2678        let got = AthenaConfig::resolve_config_path(
2679            Some(&flag),
2680            Some(&env),
2681            Path::new("/nonexistent"),
2682            Some(Path::new("/nonexistent")),
2683        );
2684        assert_eq!(got.as_deref(), Some(flag.as_path()));
2685    }
2686
2687    #[test]
2688    fn env_wins_over_walkup_and_global() {
2689        let env = PathBuf::from("/tmp/env-athena.toml");
2690        let got = AthenaConfig::resolve_config_path(
2691            None,
2692            Some(&env),
2693            Path::new("/nonexistent"),
2694            Some(Path::new("/nonexistent")),
2695        );
2696        assert_eq!(got.as_deref(), Some(env.as_path()));
2697    }
2698
2699    #[test]
2700    fn empty_env_is_treated_as_unset() {
2701        // A set-but-empty `$ATHENA_CONFIG` must fall through, not shadow the
2702        // walk-up / global fallback (the source-free path).
2703        let cwd = tmpdir("empty-env-cwd"); // no athena.toml on the walk-up
2704        let xdg = tmpdir("empty-env-xdg");
2705        let cfg = touch(&xdg, "athena.toml");
2706        let got = AthenaConfig::resolve_config_path(None, Some(Path::new("")), &cwd, Some(&xdg));
2707        assert_eq!(got.map(canon), Some(canon(cfg)));
2708        let _ = std::fs::remove_dir_all(&cwd);
2709        let _ = std::fs::remove_dir_all(&xdg);
2710    }
2711
2712    #[test]
2713    fn walkup_finds_athena_toml_in_ancestor() {
2714        let root = tmpdir("walkup");
2715        let cfg = touch(&root, "athena.toml");
2716        let nested = root.join("a").join("b");
2717        std::fs::create_dir_all(&nested).unwrap();
2718        // No flag/env; the global must be ignored because walk-up hits first.
2719        let got =
2720            AthenaConfig::resolve_config_path(None, None, &nested, Some(Path::new("/nonexistent")));
2721        assert_eq!(got.map(canon), Some(canon(cfg)));
2722        let _ = std::fs::remove_dir_all(&root);
2723    }
2724
2725    #[test]
2726    fn global_fallback_when_no_repo_config() {
2727        let cwd = tmpdir("global-cwd"); // no athena.toml anywhere on the walk-up
2728        let xdg = tmpdir("global-xdg");
2729        let cfg = touch(&xdg, "athena.toml");
2730        let got = AthenaConfig::resolve_config_path(None, None, &cwd, Some(&xdg));
2731        assert_eq!(got.map(canon), Some(canon(cfg)));
2732        let _ = std::fs::remove_dir_all(&cwd);
2733        let _ = std::fs::remove_dir_all(&xdg);
2734    }
2735
2736    #[test]
2737    fn none_when_nothing_resolves() {
2738        let cwd = tmpdir("none");
2739        let got = AthenaConfig::resolve_config_path(
2740            None,
2741            None,
2742            &cwd,
2743            Some(Path::new("/definitely/not/here")),
2744        );
2745        assert_eq!(got, None);
2746        let _ = std::fs::remove_dir_all(&cwd);
2747    }
2748
2749    // ---- fail-loud guards --------------------------------------------
2750
2751    /// Inventory is empty in this test binary, so `collect` gives a
2752    /// fragment-free ctx — enough to exercise `resolved_secrets`' own
2753    /// merge/collision rules.
2754    fn empty_ctx() -> super::BuildCtx {
2755        super::BuildCtx::collect("t", "0.0.0", "t", None, None, None)
2756    }
2757
2758    #[test]
2759    fn resolved_secrets_required_wins_over_optional() {
2760        let ctx = empty_ctx();
2761        // Optional seen first, required later: the emitted entry must be
2762        // required, else a missing secret panics mid-body in-pod instead
2763        // of failing pod-start.
2764        let got = ctx.resolved_secrets(&[("s", "k", true), ("s", "k", false)], &[]);
2765        assert_eq!(got, vec![("s".to_string(), "k".to_string(), false)]);
2766        // And the reverse order too.
2767        let got = ctx.resolved_secrets(&[("s", "k", false), ("s", "k", true)], &[]);
2768        assert_eq!(got, vec![("s".to_string(), "k".to_string(), false)]);
2769    }
2770
2771    #[test]
2772    #[should_panic(expected = "both map to the env var")]
2773    fn resolved_secrets_env_collision_fails_loud() {
2774        // "a.b" and "a-b" are distinct valid K8s secret names but
2775        // flatten to the same ATHENA_SEC_A_B__K env var.
2776        let ctx = empty_ctx();
2777        ctx.resolved_secrets(&[("a.b", "k", false), ("a-b", "k", false)], &[]);
2778    }
2779
2780    #[test]
2781    fn artifact_idents_pass_distinct_keys() {
2782        let keys = vec!["data/in.json".to_string(), "data/out.json".to_string()];
2783        assert_eq!(
2784            super::artifact_idents_checked(&keys),
2785            vec!["data-in-json".to_string(), "data-out-json".to_string()],
2786        );
2787        // The same key twice is the caller-dedup contract, not a clash.
2788        let dup = vec!["a.b".to_string(), "a.b".to_string()];
2789        super::artifact_idents_checked(&dup);
2790    }
2791
2792    #[test]
2793    #[should_panic(expected = "both flatten to the Argo artifact name")]
2794    fn artifact_ident_collision_fails_loud() {
2795        let keys = vec!["a.b".to_string(), "a-b".to_string()];
2796        super::artifact_idents_checked(&keys);
2797    }
2798
2799    #[test]
2800    #[should_panic(expected = "duplicate Argo template name")]
2801    fn collector_enter_rejects_same_name_different_type() {
2802        struct A;
2803        struct B;
2804        impl super::Template for A {
2805            const ARGO_NAME: &'static str = "dup-name";
2806            const INPUTS: &'static [&'static str] = &[];
2807            const KIND: super::TemplateKind = super::TemplateKind::Container;
2808            fn build(_: &super::BuildCtx) -> crate::api::Template {
2809                Default::default()
2810            }
2811            fn collect(_: &mut super::Collector) {}
2812        }
2813        impl super::Template for B {
2814            const ARGO_NAME: &'static str = "dup-name";
2815            const INPUTS: &'static [&'static str] = &[];
2816            const KIND: super::TemplateKind = super::TemplateKind::Container;
2817            fn build(_: &super::BuildCtx) -> crate::api::Template {
2818                Default::default()
2819            }
2820            fn collect(_: &mut super::Collector) {}
2821        }
2822        let mut c = super::Collector::new();
2823        assert!(c.enter::<A>());
2824        assert!(!c.enter::<A>()); // same type re-visit: fine, deduped
2825        c.enter::<B>(); // different type, same name: panics
2826    }
2827}