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}