cargo-athena
Write a normal Rust program. Get an Argo Workflow.
cargo-athena compiles ordinary, annotated Rust into Argo Workflows YAML - and ships your compiled binary so every step runs your real code.
use cargo_athena::{workflow, container};
#[workflow]
fn pipeline() {
let raw = fetch("https://example.com/data".to_string()); // a #[container]
let clean = transform(raw, 3);
publish(clean); // a #[container]
}
#[container(image = "ghcr.io/acme/app:latest")]
fn transform(data: String, factor: i64) -> String {
format!("{data} x{factor}") // this actually runs in the pod
}
That #[workflow] becomes an Argo WorkflowTemplate whose DAG wires
fetch → transform → publish by their data dependencies. transform
becomes a container template of its own. In-pod, your binary
deserializes data and factor, runs the function body, and
serializes the result for the next step.
# `cargo athena emit` → one WorkflowTemplate per fn, wired by name:
kind: WorkflowTemplate # the #[workflow] is a DAG
metadata: { name: app-pipeline }
spec:
templates:
- dag:
tasks:
- { name: fetch }
- { name: transform, dependencies: [fetch] } # data dep → edge
- { name: publish, dependencies: [transform] }
# …plus one WorkflowTemplate per #[container]: your image, your binary,
# and a tiny bootstrap that runs the right function.
Why
- No YAML. The workflow is the program. Refactor with the compiler, not a templating language.
- Type-checked data flow. Passing the wrong type between steps, a missing struct field, or consuming a step that returns nothing is a compile error - caught long before a cluster ever sees it.
- Composable. A workflow is a Rust type. Reference it from another crate and it comes along automatically: workflows compose across modules and crates with no registry or hand-run codegen.
- Real Rust in any image. Your binary is fetched at runtime, so
each step runs your Rust on top of any image you pick - a vendor’s
postgres:16, a CUDA base, your team’s tooling image - with no custom Dockerfile per step. The image just needsshanduname.
How it fits together
| You write | cargo-athena produces |
|---|---|
#[workflow] fn | an Argo WorkflowTemplate (a DAG, or sequential steps) |
#[container] fn | an Argo WorkflowTemplate (a container step) that runs your real Rust |
#[fragment] fn | a plain helper that carries pod resources into its callers |
fn main() | the entrypoint into your workflow binary |
Ship and run it in two commands: cargo athena publish (cross-compile
and upload the binary), then cargo athena submit <workflow> (registers
the templates and starts the run) - see the CLI.
Read Getting Started to go hands-on, then
Core Concepts for the mental model. The
#[workflow] and #[container] pages are
the complete feature reference.
Getting Started
From nothing to a running workflow. Assumes you have an S3-compatible bucket and a reachable Argo cluster.
1. Install cargo-athena
cargo install cargo-athena # the `cargo athena` CLI
You’ll add the library to your workflow crate in step 3 via
cargo athena init; or you can add it to an existing crate with
cargo add cargo-athena --no-default-features.
⚠️ Library users:
--no-default-features. A workflow crate needs only the macros + runtime; the defaultclifeature pulls a heavy CLI tree (kube,reqwest,tokio, …) it doesn’t use.
2. Set up the publish toolchain
cargo athena publish cross-compiles your crate as static-musl
binaries for the architectures in your athena.toml (Linux pods run
musl). You need three things on the machine that runs publish:
# (a) cargo-zigbuild and the Zig linker
cargo install cargo-zigbuild
pip install ziglang # or: brew install zig
# or: https://ziglang.org/download/
# (b) Rust standard library for each target arch in athena.toml
rustup target add x86_64-unknown-linux-musl
rustup target add aarch64-unknown-linux-musl
Run cargo athena doctor to verify every prereq is green before you
try to publish:
cargo athena doctor
# checks cargo-zigbuild, zig, rustup targets, athena.toml, AWS creds
No toolchain needed for emit or submit - those don’t compile
anything. Useful if you build the tarball on CI and only submit from
elsewhere.
The repo also ships a Nix flake that installs the full toolchain with one command. If you use Nix:
nix profile install github:mostlymaxi/cargo-athena nix develop github:mostlymaxi/cargo-athena # or just enter a shell
3. Scaffold a workflow crate
cargo athena init my-pipeline # prompts for bucket/endpoint/region
cd my-pipeline
This writes a runnable starter (a one-container hello pipeline)
plus an athena.toml skeleton:
my-pipeline/
Cargo.toml # cargo-athena dep, default-features = false
src/main.rs # #[workflow] pipeline() { hello("world") }
athena.toml # your S3 bucket + bootstrap targets
Pass -y to accept defaults without prompts, or flags like
--bucket, --endpoint, --region for a fully scripted run.
Already have a crate? Add cargo-athena with
cargo add cargo-athena --no-default-featuresand skipinit. Seeathena.tomlfor the config format.
4. Write your pipeline
Open src/main.rs and replace the hello starter with whatever you
need. A typical multi-step pipeline:
use cargo_athena::{container, workflow};
#[workflow]
fn pipeline() {
let raw = fetch("https://example.com/data".to_string());
let summary = summarize(raw, 3);
publish(summary);
}
#[container(image = "ghcr.io/acme/app:latest")]
fn fetch(url: String) -> String {
format!("data-from:{url}")
}
#[container]
fn summarize(data: String, top_n: i64) -> String {
format!("top-{top_n}:{data}")
}
#[container]
fn publish(report: String) {
println!("publishing {report}");
}
fn main() {
cargo_athena::entrypoint!(pipeline);
}
Data flow becomes the DAG. See Core Concepts and the Cookbook for what else you can do.
5. Ship it
cargo athena emit # inspect the YAML, no infra needed
cargo athena publish # cross-compile + upload the binary
cargo athena submit my-pipeline-pipeline
submit runs the safe-deploy preflight for you (type-check the args,
confirm the binary is uploaded, register every WorkflowTemplate with a
y/N on drift) and then creates the run. S3 credentials come from the
standard AWS env vars or instance-role identity. See
the CLI page for the steps in full and the -y,
--update, and --argo-server flags.
Automate it. To build and publish from GitHub Actions without wiring up the toolchain yourself, use the
athena-publishaction. See Publishing from CI.
GitOps alternative:
cargo athena emit | kubectl apply -f -registers the templates;argo submit --from workflowtemplate/<root>runs them. Names are stable and deterministic.
Want to try one step locally before deploying?
cargo athena emulate runs a single #[container]
under docker / podman exactly as Argo would; see Testing
for the full inner-loop options.
Next: Core Concepts.
Core Concepts
A few ideas explain everything cargo-athena does.
1. Templates are types
Each #[workflow] / #[container] lowers to a Rust type. Referencing
that type from another module or crate pulls its definition into the
build; emitting your entrypoint includes every template it reaches,
and nothing it doesn’t.
There is no registry to keep in sync. Workflows compose across modules and crates through normal Rust name resolution.
2. #[workflow] is a statically analyzed DAG
A workflow body is read, not executed. Each let x = t(args);
or t(args); becomes a step; data flow becomes the DAG edges.
Because the body is read, it is also type-checked as ordinary
Rust. Wrong types, wrong arity, missing fields, or calling a
non-template are compile errors. Only the lowered shapes
(let / call statements, if / else) are accepted; anything else
is a spanned compile_error!. Full details on the
#[workflow] page.
3. #[container] runs real Rust in a pod
A container body really does execute inside its pod. Arguments come
in as inputs; the return value goes out as the step’s output. I/O
is serde-bound at compile time, so take and return owned types
(String, not &str).
Arguments can also be spliced into the pod spec: writing
image = "repo:" + tag injects an argument into the image (and
likewise into service_account, node_selector, env, and the
mutex name / namespace). See
#[container] Parameter injection.
4. #[fragment] carries pod resources
A fragment is a normal helper that runs as real Rust code in the
calling container’s pod. It can take arguments and return values
like any function. Its only superpower: every host! mount, S3
artifact port, or secret! env it declares is also added to each
container that transitively calls it.
Share setup logic, mounts, and secrets across containers without a registry.
5. Your workflow binary runs in two worlds
The binary your workflow crate compiles to plays two roles:
- On your machine,
cargo athena emit/publish/submitwalks the template closure from your entrypoint and prints oneWorkflowTemplateper template. - In Argo, that same binary deserializes the step’s inputs,
calls the matching
#[container]body, and serializes the return.
cargo athena publish cross-compiles it static-musl and uploads it;
emit adds it to every container template; a tiny sh bootstrap
picks the matching architecture and runs the right function. The
image needs only sh and uname.
With these in mind, the reference pages are the details:
#[workflow], #[container],
the CLI, and athena.toml.
#[workflow]
A #[workflow] is an Argo DAG. Its body is statically analyzed, not
executed: each statement becomes an Argo task, data flow becomes a
DAG edge, and the function name becomes a WorkflowTemplate. The
entrypoint is a type:
fn main() { cargo_athena::entrypoint!(run_foo); }
The body is also type-checked as ordinary Rust. Wrong argument types
or arity, a missing struct field, consuming a workflow that has no
return, or calling a #[fragment] / regular function from a
#[workflow] are all compile errors.
Attribute arguments
#[workflow(name = "...", steps,
boundary_node_selector = { "k" = "v" },
node_selector_if_root = { "k" = "v", "k2" = "lit" + arg },
on_exit_if_root = path::to::template,
retry(limit = 2, policy = "OnError", backoff = "30s"),
ttl_if_root(after_completion = 86400, after_success = 3600, after_failure = 7200),
pod_gc_if_root(strategy = "OnWorkflowSuccess"),
active_deadline_if_root = "2h",
mutexes = [{ name = "pipeline-dag" }],
mutexes_if_root = [{ name = "deploy-" + env }])]
All are optional.
| Arg | Effect |
|---|---|
name = "my-name" | Override the Argo template name. Default <crate>-<fn> (kebab). |
steps | Emit an Argo steps: (sequential) template instead of the default data-dependency dag:. |
boundary_node_selector = { … } | A nodeSelector constraint on pods whose immediate enclosing dag/steps is this template. Does NOT cascade through nested sub-workflows. Literal only. See Node selector. |
node_selector_if_root = { … } | Default nodeSelector for every pod in the submitted run. Root-only. Values support "lit" + arg / "lit" + arg.field injection of the workflow’s own arguments. |
annotations = { "k" = "v" } | Template-level annotations on the dag/steps template. Literal keys and values. |
on_exit_if_root = t | Whole-workflow exit handler that fires only when this template is the workflow you submit. Distinct from the per-task .on_exit(t) builder. |
retry(limit, policy, backoff) | Template-level retry. limit is required (unlimited means no cap); policy ∈ Always / OnFailure / OnError / OnTransientError; backoff is seconds or a humantime string. |
ttl_if_root(after_completion, after_success, after_failure) | GC the finished Workflow after the given duration. At least one of the three is required. Root-only. |
pod_gc_if_root(strategy) | Pod garbage collection. strategy ∈ OnPodCompletion / OnPodSuccess / OnWorkflowCompletion / OnWorkflowSuccess. Root-only. |
active_deadline_if_root = <dur> | Whole-workflow runtime cap. The only timeout that works on a #[workflow]. Root-only. See Timeouts. |
mutexes = [{ name, namespace }, …] | Serialize this template against other holders of the same mutex name (within one run AND across separate Workflow runs). Both fields accept "lit" + arg + arg.field injection. See Mutexes. |
mutexes_if_root = [{ name, namespace }, …] | Serialize the whole submitted run against other runs holding the same mutex. Root-only. |
tolerations_if_root = [{ key, operator, value, effect, ... }, …] | K8s Toleration list applied to every pod in the run. Strings accept "lit" + arg injection. Root-only. |
affinity_if_root = "<json|yaml>" | Opaque YAML/JSON string for pod affinity, applied to every pod in the run. athena keeps it opaque rather than modelling the deeply-nested Kubernetes Affinity schema; use pod_spec_patch_if_root for patch-style. Hand-write {{workflow.parameters.X}} substitutions inside the YAML body as needed. Root-only. |
pod_spec_patch_if_root = "<json|yaml>" | Strategic-merge patch Argo applies to every pod in the submitted run. Universal escape hatch for any podSpec field athena doesn’t have a first-class attr for. String accepts "lit" + arg injection. Root-only. athena does NOT validate the patch shape; Argo / k8s reject malformed input at submit / admission time. |
image_pull_secrets_if_root = ["regcred", …] | Root-only WorkflowSpec.ImagePullSecrets. Secret names the kubelet uses to pull every pod’s image from a private registry. K8s / Argo expose this only at workflow scope; per-container needs go through pod_spec_patch. |
parallelism = N | Template.parallelism on this dag/steps. Caps concurrent children scheduled under THIS template invocation only (pods from nested templates don’t count). Literal i64, > 0. |
parallelism_if_root = N | Root-only WorkflowSpec.parallelism. Caps total concurrent pods across the run. Inert when this WT is templateRef’d. Literal i64, > 0. |
boundary_tolerations = [{ key, operator, value, effect, ... }, …] | K8s Toleration list on this dag/steps template, inherited by child pods that don’t set their own. Same boundary tier as boundary_node_selector. Literal only (use tolerations_if_root for values that depend on an argument). |
boundary_affinity = "<json|yaml>" | Opaque YAML/JSON string for pod affinity on this dag/steps template, inherited by child pods that don’t set their own. Literal only. |
A parameter name (i.e. a function argument) or a name = "…"
value that a YAML 1.1 parser reads as a boolean/null (y / yes /
n / no / on / off / true / false / null / ~, any
case) is a compile error: Argo’s YAML→JSON parser would silently
mis-type it.
Timeouts
To time-bound a whole workflow, use active_deadline_if_root -
the only mechanism Argo enforces at workflow scope. The other two
knobs (timeout, pod_running_timeout) are per-pod and live on
#[container].
The _if_root suffix means the cap applies only when this
WorkflowTemplate is the workflow you actually submit; it is inert
when this template is referenced as a nested sub-workflow.
Every duration is an integer (seconds) or a
humantime string ("90s", "1h30m",
"2d").
Node selector
Three knobs at three scopes. Pick by reach:
#[container(node_selector = …)]: this one pod only. Supports"lit" + argvalue injection of the container’s own inputs.#[workflow(boundary_node_selector = …)]: pods whose immediate enclosing dag/steps is this template. Does NOT cascade through nested sub-workflows. Literal keys and values only.#[workflow(node_selector_if_root = …)]: every pod in the submitted run that doesn’t have a tighter override. Root-only (inert when referenced as a sub-workflow). Values support"lit" + arginjection of the workflow’s own arguments.
boundary_node_selector is intentionally literal-only. For a
value that depends on an argument, use node_selector_if_root, the
one #[workflow] knob where per-arg injection has clear, predictable
semantics. A hand-written {{workflow.parameters.X}} inside a literal
still works as an eyes-open escape hatch.
#[workflow(
boundary_node_selector = { "kubernetes.io/arch" = "amd64" },
node_selector_if_root = { "tier" = "platform",
"env" = "prod-" + env },
)]
fn pipeline(env: String) { /* ... */ }
Mutexes
At most one workflow / node holds a named mutex at a time, per namespace. Two separate Workflow runs sharing a mutex name will serialize against each other.
Two tiers, picked by reach:
mutexes_if_root(root-only): held for the whole submitted run. The “one of these workflows at a time” knob. Inert when referenced as a sub-workflow.mutexes(template-level): held just while this template’s node is running. Lets parallel tasks in one run serialize on a per-shard mutex name; lets a sub-workflow self-serialize wherever it’s embedded.
Each entry is { name = …, namespace = … }. namespace is optional
(defaults to the workflow’s own namespace; set it explicitly to
coordinate across namespaces). Both fields accept the
"lit" + arg + arg.field injection grammar.
Cookbook: Mutual exclusion across runs.
The body
Only three statement shapes are lowered:
let x = template(args); // a task; `x` binds its output
template(args); // a task (no output consumed)
if cond { ... } else { ... } // see "if / else" below
Everything else (match, for / while / loop, macros,
arbitrary method calls, let with non-ident/tuple patterns,
let … else) is a hard compile_error! with a spanned message.
Nothing is silently dropped.
Arguments to a template call
| Form | Semantics |
|---|---|
literal "s", 7, true | a static parameter value |
a #[workflow] input param | the workflow input, forwarded |
a prior let binding | the producer’s output + a DAG edge |
binding.clone() / binding.to_owned() | same as the binding (type-preserving) |
"lit".to_string() / "lit".into() | same as the literal (literal-only) |
binding.field.sub | one named field of the producer’s output |
a nested call foo(bar()) | bar becomes its own task; recursive |
Notes:
.clone()is the fan-out marker. Sending one binding to two consumers requires an explicit.clone()- which matches what Argo actually does (copy the parameter into each consumer)..to_string()/.into()are literal-only. On a binding or input they’re rejected: they’d change the Rust type without changing the wire value, a silent mismatch. Any literal value is fine.Artifact<T>-typed bindings wire through S3 automatically. If the producer returnsArtifact<T>and the consumer acceptsArtifact<T>, the call site looks identical to any other binding-to-arg flow. See#[container]-> Large or binary return values.
Return values
A #[workflow] with a return type bubbles its terminal task’s
output up, so a parent consumes a sub-workflow exactly like a
container:
#[workflow]
fn sub(seed: String) -> String {
let fetched = fetch(seed);
transform(fetched, 7) // tail call == this workflow's return
}
#[workflow]
fn parent() {
let r = sub("seed".to_string());
publish(r);
}
The terminal is the tail template call, a returned/tail binding, or
a value-if (below). A return type with no resolvable terminal is a
compile error.
A workflow may also return Artifact<T> to pass a large or binary
value up to its parent through S3 instead of inline:
#[workflow]
fn sub() -> cargo_athena::Artifact<Vec<u8>> {
make_report() // tail call returns Artifact<Vec<u8>>
}
Same wiring shape as a plain return; the parent just sees an
Artifact<T> value.
Per-task builder chain
A task call may be suffixed, in any order, with:
fetch(url).continue_on(failed, error); // dependents proceed on failure/error
transform(x).on_exit(cleanup); // unconditional per-task exit hook
transform(x).on_exit(record("done")); // hook target may take args
transform(x).on_success(notify).on_failure(alarm); // repeatable phase hooks
transform(x).on_error(alarm);
transform(x).hook_if("workflow.status == 'Failed'" = alarm); // raw Argo expr escape hatch
.continue_on(failed | error | failed, error): at most one; lets dependents proceed even when this task fails / errors..on_exit(t)/.on_exit(t(args)): at most one; an unconditional per-task exit hook..on_success(t)/.on_failure(t)/.on_error(t): repeatable; fire on the corresponding phase..hook_if("raw-argo-expression" = t, …): repeatable; verbatim Argo expression escape hatch.
Any hook target is t or t(args). Hook templates are reachable
from this workflow, so they get registered like any other callee.
.fan_out(|x| C(x, …)) - list fan-out
let b = a.fan_out(|x| caps(x, "!".to_string())); runs caps once
per element of a. b is the aggregated Vec<U>, consumed
downstream like any output.
amust be a priorletbinding or a#[workflow]input that is a list.- The closure body must be a single template call.
- Element / closure / result types are type-checked.
if / else / else if
Real Rust conditionals run exactly one branch:
// statement-if / else-if / else
if n == 0 {
note("zero".to_string());
} else if m.id == "abc" && n > 1 {
note(chosen);
} else {
note("other".to_string());
}
// value-if: pass the taken branch's value out
let chosen = if n > 3 { left(n) } else { right(n) };
- Conditions are a closed grammar: comparisons
== != < <= > >=combined with&&/||/!. Operands are a binding, a#[workflow]input, ana.fieldof one, a literal, or a nested template call. Anything outside this grammar (method calls, arithmetic, casts) is a targeted compile error. - Value-
ifrequires anelseand both arms producing the same type. - Bindings created inside an arm are not visible after the
if. Use the value-ifform to pass a result out.
Strict by design
If it compiles, the argument / field / return types line up and every statement was lowered. There is no silent mis-emit.
See also
Cookbook recipes that exercise these features:
- Sequential vs. parallel
- Reuse a multi-step workflow as a building block
- Inline one step’s output into another
- Fan-out over a list
- Conditionals
- Pass only one field of a struct
- Force a sequential execution order
- Per-task hooks
- Retry with backoff
- Timeouts
- Whole-workflow cleanup
- Mutual exclusion across runs
- Throttle pods per workflow / per DAG
- Pin every step in a workflow to specific nodes
- Tolerate node taints and steer with affinity
- Reach a podSpec field athena has no attr for
- Pull images from a private registry
Hitting an error? See Troubleshooting.
#[container]
A #[container] is a workflow step whose body is ordinary Rust,
executed in a pod. Unlike a #[workflow] (statically analyzed), a
container body really runs: arguments come in, the function executes,
and its return value goes out.
#[container(image = "ghcr.io/acme/app:latest")]
fn run_a_container(a: String) -> String {
println!("regular code, got: {a}");
format!("done:{a}")
}
The image is arbitrary; it needs only POSIX sh and uname, so
distroless and read-only-rootfs images work fine.
I/O contract
- Each function argument is one input parameter the step receives.
- The return value is the step’s output; the next
#[workflow]task consumes it like any binding. - I/O is compile-time bound to
serde(DeserializeOwned/Serialize). Borrows can’t cross this boundary - take and return owned types (String, not&str).
Large or binary return values
By default a step’s return rides Argo as an inline parameter, which
is fine for small JSON. For large or binary payloads, wrap the value
in cargo_athena::Artifact<T> and it flows through your bucket
instead:
use cargo_athena::{container, Artifact};
#[container]
fn make_report() -> Artifact<Vec<u8>> {
Artifact::new(build_pdf())
}
#[container]
fn ship(r: Artifact<Vec<u8>>) {
upload(r.into_inner());
}
The wiring is automatic: any #[workflow] task that consumes an
Artifact<T>-typed value is hooked up via S3 instead of inline
parameters. Reach for it when the value is “big enough you’d worry
about it” - tens of KB or more, encoded binary blobs, file-shaped
data.
Plain returns and Artifact<T> returns mix freely inside the same
workflow; choose per step.
Attribute arguments
#[container(
image = "ghcr.io/acme/app:latest",
name = "...",
service_account = "athena-runner",
node_selector = { "kubernetes.io/arch" = "amd64", "disktype" = "ssd" },
on_exit_if_root = path::to::template,
retry(limit = 3, policy = "OnError", backoff = "30s"),
timeout = "5m",
pod_running_timeout = 600,
ttl_if_root(after_completion = 86400, after_success = 3600, after_failure = 7200),
pod_gc_if_root(strategy = "OnWorkflowSuccess"),
active_deadline_if_root = "2h",
mutexes = [{ name = "writer-" + shard }],
mutexes_if_root = [{ name = "global-deploy" }],
)]
All optional.
| Arg | Effect |
|---|---|
image = "…" | Container image. Default [bootstrap].default_image from athena.toml. |
name = "…" | Override the Argo template name. Default <crate>-<fn> (kebab). |
service_account = "…" | Pod ServiceAccount. Default [defaults].service_account. |
node_selector = { … } | Pin pods of this template to nodes matching the labels. Literal keys; values may be injected (see Parameter injection). |
env = { "K" = "v", … } | Extra container env entries the body reads via std::env::var(…). Literal keys; values follow the same "lit" + arg + … injection grammar as image. |
host_mount = [{ host_path, mount_path, read_only }, …] | Explicit hostPath mounts with chosen mount paths. Use when you really do want a specific in-container path (/dev/shm, sidecar data, …); host! is the safer form. read_only defaults to false. Dedup’d against host! paths on the same host_path. |
annotations = { "k" = "v", … } | Pod-template annotations. Literal keys; values injectable like env. |
privileged = true | K8s securityContext.privileged: true on this container. Off by default; opt in only when you really do need host devices / kernel-level access. Your cluster’s PodSecurity admission still has the final say. |
daemon (or daemon = true) | Argo Template.daemon: true: the pod runs long-lived and the workflow proceeds to dependent tasks once the container reaches readiness (not completion); Argo terminates it when the enclosing dag/steps finishes. #[container]-only (#[workflow(daemon)] is a compile error). Caveats: a daemon that exits Succeeded is marked failed (daemons are meant to run indefinitely), and retry only covers startup — once ready, pod failures are ignored. athena has no readinessProbe attr; add one via pod_spec_patch if Ready-timing matters. |
on_exit_if_root = t | Whole-workflow exit handler that fires only when this template is the workflow you submit. Distinct from the per-task .on_exit(t) builder. |
retry(limit, policy, backoff) | Template-level retry. limit is required (unlimited means no cap); policy ∈ Always / OnFailure / OnError / OnTransientError; backoff is seconds or a humantime string. |
timeout = <dur> | Per-step timeout that counts Pending time. See Timeouts. |
pod_running_timeout = <dur> | Per-step timeout that only counts time the pod is Running. |
ttl_if_root(after_completion, after_success, after_failure) | GC the finished Workflow after the given duration. At least one of the three is required. Root-only. |
pod_gc_if_root(strategy) | Pod garbage collection. strategy ∈ OnPodCompletion / OnPodSuccess / OnWorkflowCompletion / OnWorkflowSuccess. Root-only. |
active_deadline_if_root = <dur> | Whole-workflow runtime cap. Root-only. See Timeouts. |
mutexes = [{ name, namespace }, …] | Serialize pods of this template against any other holder of the same mutex name (within one run AND across separate Workflow runs). Both fields accept "lit" + arg + arg.field injection. |
mutexes_if_root = [{ name, namespace }, …] | Serialize the whole submitted run against other runs holding the same mutex. Root-only. |
tolerations = [{ key, operator, value, effect, toleration_seconds }, …] | K8s Toleration list on this template’s pod. Required: key, operator ("Equal" | "Exists"), effect ("NoSchedule" | "PreferNoSchedule" | "NoExecute"). Optional: value (required only with Equal), toleration_seconds (NoExecute only). key, value, effect accept "lit" + arg injection. |
tolerations_if_root = [{ key, operator, value, effect, ... }, …] | Same, but applied to every pod in the run (3rd tier of Argo’s tmpl → boundary → wfSpec lookup). Root-only. |
affinity = "<json|yaml>" | Opaque YAML/JSON string for K8s pod affinity. athena keeps it opaque rather than modelling the deeply-nested Kubernetes Affinity schema; use pod_spec_patch if you’d rather express it patch-style. |
affinity_if_root = "<json|yaml>" | Same, but applied to every pod in the run. Root-only. Hand-write {{workflow.parameters.X}} substitutions inside the YAML body if you need dynamic values. |
pod_spec_patch = "<json|yaml>" | Strategic-merge patch applied to this template’s pod just before submission. Universal escape hatch for any podSpec field athena doesn’t have a first-class attr for (resources, sidecars, init containers, fsGroup, …). String accepts "lit" + arg injection. Dangerous-by-design: athena does NOT validate the patch shape; Argo / k8s reject malformed input at submit / admission time. |
pod_spec_patch_if_root = "<json|yaml>" | Same, but applied to every pod in the run. Argo concats with each template’s own pod_spec_patch. Root-only. |
image_pull_secrets_if_root = ["regcred", …] | Root-only WorkflowSpec.ImagePullSecrets. Secret names the kubelet uses to pull every pod’s image from a private registry. K8s / Argo expose this only at workflow scope; per-container needs go through pod_spec_patch. |
Per-argument #[inject("...")]
Mark a function parameter as filled by Argo’s substitution rather than
a normal inputs.parameters entry. The expression is passed verbatim
to Argo: athena does NOT validate it (the user owns Argo’s variable
scope rules and any JSON wrapping needed for the arg type).
#[container]
fn smart_retry(
payload: String, // normal input
#[inject("{{retries}}")] attempt: i64, // bare numeric
#[inject("\"{{pod.name}}\"")] pod: String, // quoted string
) { /* ... */ }
A normal parameter contributes its inputs.parameters value; an
#[inject] parameter contributes your raw expression verbatim. Each
is decoded back into its Rust type the same way, so match the wrapping
to the type:
- numeric / bool types: a bare expression (
{{retries}}→3) String(and friends): wrap in"..."yourself ("\"{{pod.name}}\""→"podname")
Workflow bodies call the template without passing inject args: they’re invisible to the caller. Allowed anywhere in the signature.
This is dangerous by design: athena trusts the expression. Argo’s
admission rejects unknown variables at submit; out-of-scope refs
({{tasks.X.outputs.parameters.Y}} from a container submitted directly,
or {{retries}} outside a retry context) silently leave the placeholder
in place and panic the run-side decode. Power-user escape hatch.
As with #[workflow], an argument name or a name = "…" value
that a YAML 1.1 parser reads as a boolean/null is a compile error.
Timeouts
Three “stop after a while” knobs:
| Attribute | What it bounds | Clock starts |
|---|---|---|
timeout | this step | when the node is created (includes Pending) |
pod_running_timeout | this pod | when the pod is Running |
active_deadline_if_root | the whole submitted run | at workflow start (root-only) |
A pod stuck Pending trips timeout but not pod_running_timeout.
The first two are #[container]-only and are rejected on a
#[workflow]. The only working whole-workflow timeout is
active_deadline_if_root.
Every duration accepts an integer (seconds) or a
humantime string ("90s", "1h30m",
"2d").
Parameter injection
image, service_account, env, node_selector, annotations,
and the mutex name / namespace values can splice in the
container’s own arguments:
#[container(
image = "ghcr.io/acme/app:" + tag, // arg
service_account = "athena-" + tenant + "-runner", // literal + arg + literal
node_selector = { "kubernetes.io/arch" = "amd64",
"disktype" = profile.disk }, // a named struct field
)]
fn run(tag: String, tenant: String, profile: Profile) { /* ... */ }
Rules:
- The value is a string literal, or a
+-concatenation of string literals and operands. An operand is an argument (tag) or a named struct field of one (profile.disk,a.b.c; noa.0/a[i]). - String-literal segments are emitted verbatim - a hand-written
{{workflow.parameters.x}}inside a literal passes through untouched (eyes-open escape hatch). - Operands must be
String/&stror a number (i64,f64, …). Anything else is a compile error, because only those round-trip to a raw scalar value. node_selectorkeys are always literal (a dynamic label key would be a foot-gun).nameis the static template identity andon_exit_if_rootis a template path; neither is an injection target.
#[fragment]
A #[fragment] is a plain helper function, not a template. It
is called as ordinary Rust, so it executes inside the calling
container’s pod:
#[container(image = "ghcr.io/acme/tools:latest")]
fn build() {
frag_a();
}
#[fragment]
fn frag_a() {
let _ = cargo_athena::host!("/var/lib/a");
frag_b(); // transitive
}
#[fragment]
fn frag_b() { let _ = cargo_athena::host!("/var/lib/b"); }
Its purpose is to carry pod-resource declarations across function
boundaries. Every host! / artifact-port / secret! a fragment
uses is added to each #[container] that transitively calls it. A
#[fragment] cannot be called from a #[workflow] (it is not a
template; doing so is a type error).
Macro calls
These declare pod resources and are only valid inside a
#[container] or #[fragment]. Calling them anywhere else is a
compile error.
| Macro | Effect | Runtime value |
|---|---|---|
host!("/abs/path") | a hostPath volume mounted at a safe path the macro picks. | &'static Path (the path your code reads/writes; use path.join("file"), path.display(), etc.) |
load_artifact!("key") | S3 input at the given object key | Vec<u8> |
load_artifact_str!("key") | same, as text | String |
save_artifact!("key", bytes) | S3 output at the given object key | writes impl AsRef<[u8]> |
save_artifact_str!("key", text) | same, as text | writes impl AsRef<str> |
secret!("name", "key") | a K8s Secret env on this container | String (panics if unset) |
secret_opt!("name", "key") | same, optional | Option<String> |
pvc!(MyPvc) | mount a PVC declared via #[ephemeral_pvc] / #[external_pvc] on this pod. | &'static Path (the mount path; use path.join("file"), etc.) |
#[container]
fn publish(report: String) {
let notes = cargo_athena::load_artifact_str!("notes");
println!("publishing {report} (notes: {notes})");
cargo_athena::save_artifact!("receipt", format!("ok:{report}"));
}
Key properties:
- Literal key only. The argument is the exact S3 object key
(for artifacts) or absolute path (for
host!) - a string literal or const, resolved at compile time. - Collected from every branch. Declarations are picked up from
every
if/match/ loop branch in the body, not just the one path that runs. The pod’s spec is fixed before the pod starts, so this is the only correct behavior. - Artifacts are decoupled. A producer and consumer that share only an S3 key have no DAG dependency or ordering. A missing object is a runtime error in the consumer.
- Carried through fragments transitively, as above.
Used path-qualified (cargo_athena::host!) by convention so it
doesn’t require a use and the gating compile errors stay obvious.
PVCs
Declare a PersistentVolumeClaim as a unit struct and mount it with
pvc!(Type) inside any #[container] / #[fragment]. Two flavors:
// Per-workflow-run PVC. Argo creates it at workflow start and
// deletes it at workflow end via `WorkflowSpec.volumeClaimTemplates`.
#[cargo_athena::ephemeral_pvc(
size = "10Gi",
access_modes = ["ReadWriteMany"],
storage_class = "fast-ssd", // optional; "" = cluster default
)]
pub struct BuildCache;
// Pre-existing PVC reference. athena emits nothing at the workflow
// spec level - the PVC must already exist in the workflow's
// namespace. `read_only` defaults to false.
#[cargo_athena::external_pvc(claim_name = "shared-data-pvc", read_only = true)]
pub struct SharedData;
#[container]
fn build() {
let cache: &Path = cargo_athena::pvc!(BuildCache);
let shared: &Path = cargo_athena::pvc!(SharedData);
std::fs::write(cache.join("result.bin"), b"...").unwrap();
}
The mount path is deterministic but opaque (/athena/pvcs/<hash>).
Use the returned &'static Path value; never hard-code the path.
Argo CRD requires access_modes to be one of ReadWriteOnce,
ReadWriteMany, ReadOnlyMany, ReadWriteOncePod. Multiple
containers sharing the same #[ephemeral_pvc] concurrently need
ReadWriteMany; ReadWriteOnce will fail the second pod’s attach.
Over-inclusion caveat (v1)
Every #[ephemeral_pvc] linked into your binary lands in
WorkflowSpec.volumeClaimTemplates on every emitted WorkflowTemplate,
so Argo creates ALL of them at workflow start - even ones the
submitted root doesn’t reach. Two ways this bites in practice:
- Multi-workflow binaries (multiple
#[workflow]fns in one bin with disjoint PVCs): submitting workflow A creates B’s PVCs too. - Library crates declaring
#[ephemeral_pvc]structs: any downstream crate that imports the library inherits the PVCs in its emitted YAML, whether it uses them or not.
Functionally harmless (the extra PVCs are created and immediately
GC’d at workflow end), but it churns cluster resources. The simplest
mitigation is one workflow per binary and keep #[ephemeral_pvc]
declarations in the same crate as their consumer.
A future PR may add per-WT reachability filtering; until then, prefer external PVCs (managed out-of-band) for PVCs whose lifetimes extend beyond a single workflow run.
Async #[container]
Mark a container async fn and the macro wraps the body in a
current-thread tokio runtime built per invocation. Enable the
tokio feature on cargo-athena to opt in - tokio is re-exported.
// Cargo.toml: cargo-athena = { …, features = ["tokio"] }
#[container]
async fn fetch(url: String) -> String {
cargo_athena::tokio::time::sleep(std::time::Duration::from_millis(10)).await;
format!("data-from:{url}")
}
#[workflow] bodies are statically analyzed, so
#[workflow] async fn is a compile error.
See also
Cookbook recipes that exercise these features:
- Share data between steps without a dependency
- Share data and keep a strict order
- Retry with backoff
- Timeouts
- Mutual exclusion across runs
- Pin a single pod (image, service account, node)
- Tolerate node taints and steer with affinity
- Reach a podSpec field athena has no attr for
- Pull images from a private registry
- Inject an Argo built-in variable as a parameter
- Pull a Kubernetes Secret as an env var
- Reuse setup across containers
- Set up tracing once for every container
- Async
#[container]fns - Share a PVC across containers in a workflow
Hitting an error? See Troubleshooting.
The cargo athena CLI
After cargo install cargo-athena you have the cargo athena
subcommand. The consumer commands (emit, ls, describe, emulate,
submit) act on a workflow binary: pass one as [BINARY] (a path,
or a name on $PATH from cargo install) and they need no source.
Omit it to build from the current crate instead (the developer loop), or
point at another crate with --manifest-path. build and publish
always build from source.
cargo athena [-c F] init [PATH] [--name N] [--bucket B] [--endpoint E] [--region R] [-y]
cargo athena [-c F] doctor [--check-s3]
cargo athena [-c F] emit [BINARY] [--out F] [--with-workflow]
cargo athena [-c F] ls [BINARY] [--kind container|workflow] [--include-synthetic]
cargo athena [-c F] describe [BINARY] [-w TEMPLATE] [--json]
cargo athena [-c F] emulate [BINARY] [-w TEMPLATE] [-a k=v].. [--input-file F]
[--build|--tarball F] [--runtime R] [--skip-artifacts]
cargo athena [-c F] submit [BINARY] [-w TEMPLATE] [-a k=v].. [-n NS] [--service-account SA]
[--node-selector k=v].. [--priority N] [--argo-server URL] [-y] [--update]
cargo athena [-c F] build [-p PKG] [--bin B] [--target T].. [--allow-dirty] [--dev-tag T] [-y] [--print]
cargo athena [-c F] publish [-p PKG] [--bin B] [--target T].. [--tarball F] [--allow-dirty] [--dev-tag T] [-y] [--print]
cargo athena [-c F] prune <TAG> [BINARY] [--keep-binary] [-n NS] [--argo-server URL] [-y]
[BINARY] a cargo-athena binary (path or $PATH name). Omit to build from
source instead: --manifest-path DIR / -p PKG / --bin B (default: the cwd crate).
The typical flow is publish to ship the binary, then
submit to register the templates and start a run. Use
init to scaffold a fresh crate and doctor to
check that your toolchain is ready.
-c, --config <FILE> (global) points at an athena.toml. With no flag
it is discovered automatically ($ATHENA_CONFIG, the nearest
athena.toml walking up from the cwd, then a global one), so submit /
emit / ls / describe work against an already-published workflow
with no per-repo config. See athena.toml for the
full precedence order.
Versioning
Every emitted WorkflowTemplate carries a version tag in its name
(<crate>-<fn>-<tag>), so a cluster can hold many versions of the same
template-set side by side. The tag is sealed into the binary at build
time by cargo athena build / publish: it is read back, never
recomputed, so editing Cargo.toml after a build cannot change a built
binary’s deployed version. A binary built with plain cargo build (no
wrapper) falls back to its CARGO_PKG_VERSION as a release tag.
There are two channels, gated like cargo publish:
-
release - a clean tree on
main(ormaster) → the tag is your crate’s semver, kebab’d (0.6.0→myapp-train-0-6-0). This is the only way to mint a clean semver name. -
dev - anything else. Two separate gates decide it:
--allow-dirty- a dirty working tree would bake uncommitted code into the binary, sobuild/publishhard-fail without it.- off a release branch - a warning + confirmation (
-y/--yesto skip, for CI).
--dev-tagnames the dev slot: bare--dev-taguses the short commit (myapp-train-dev-a1b2c3d, a new slot per commit), while--dev-tag foogives a stable slot (myapp-train-dev-foo) you overwrite while iterating. It forces the dev channel even on a clean release branch.
Each version’s binary lands at its own S3 key
({pkg}/<tag>/{bin}.tar.gz), so a dev build never clobbers a release.
Provenance rides in labels on every WT - cargo.athena/tag,
cargo.athena/channel, cargo.athena/commit, cargo.athena/dirty - so
kubectl get wt -l cargo.athena/tag=<tag> finds a version and
prune removes it.
Fast-iteration loop. build/publish resolve the tag from git, and a
source-build submit/emit (no positional [BINARY]) resolves it the
same way - so on a dev tree they agree on dev-<commit> with no setup:
cargo athena publish --allow-dirty # -> ...-dev-<commit>, uploads to .../dev-<commit>/
cargo athena submit # same dev-<commit>; deploys + pulls that binary
# ... iterate: edit, re-run publish + submit
cargo athena prune dev-<commit> # remove that version's WTs + S3 binary
The slot is the short commit, so it rolls each commit. For ONE stable
slot you overwrite in place, name it with --dev-tag - it works the same
on publish and submit/emit (each bakes that slot into its own source
build), so the names + S3 key line up:
cargo athena publish --allow-dirty --dev-tag wip # -> ...-dev-wip
cargo athena submit --dev-tag wip # -> dev-wip (matches)
cargo athena prune dev-wip
For CI / cross-machine (a build job and a separate publish job, or
publish --tarball), use ATHENA_VERSION_TAG=<tag> instead - it forces
the tag verbatim, skips git, and every step (incl. a prebuilt binary)
honors it.
Note: submit follows the binary’s baked tag. A source build (no
positional [BINARY]) bakes the git-aware tag - the auto dev-<commit>,
or your --dev-tag slot. But if you hand submit a prebuilt [BINARY],
it uses that binary’s sealed tag (and --dev-tag is rejected) - so
don’t submit a plain cargo build artifact (tagged as a release)
expecting a dev tag.
init
Scaffold a new workflow crate: writes a minimal Cargo.toml,
src/main.rs, and athena.toml in the target directory.
cargo athena init my-pipeline # interactive (prompts for bucket/endpoint/region)
cargo athena init my-pipeline -y # accept defaults, no prompts
cargo athena init -y --bucket my-bucket --region eu-west-1 .
Refuses to overwrite an existing Cargo.toml. For adding cargo-athena
to an existing crate, just run
cargo add cargo-athena --no-default-features.
Flags:
--name N- cargo package name (default: directory basename).--bucket/--endpoint/--region- prefillathena.toml.-y/--yes- skip the interactive prompts.
doctor
Preflight every prereq for publish and submit. Reports each as
green / red with a fix hint when something is missing:
cargo athena doctor
cargo athena doctor --check-s3 # also try a live HEAD on the bucket
Checks: athena.toml parses, cargo-zigbuild and zig are
installed, the rustup targets in athena.toml [bootstrap].targets
are present, and AWS_* env credentials are set (warning, not
fatal, since IMDS / IRSA cover the ambient case). With --check-s3,
also confirms the configured bucket actually responds.
Exit code is 0 on all-pass, 1 if anything failed.
emit
Prints the multi-document WorkflowTemplate YAML to stdout.
cargo athena emit ./my-workflow # a built or installed binary
cargo athena emit ./my-workflow --out wf.yaml
cargo athena emit --package my-crate | kubectl apply -f - # build from source
Names are deterministic (<crate>-<fn> kebab) so the output is
GitOps-friendly. For the typical deploy + run flow use
publish and submit instead.
Flags:
--out F- write to a file instead of stdout.--with-workflow- also append a runnableWorkflowsokubectl create -f -registers AND fires one run (handy for demos).
submit
Run a #[workflow] (or a single #[container]) on a real cluster.
cargo athena submit ./my-workflow -w pipeline -a seed=hello
W=$(cargo athena submit ./my-workflow -w pipeline -a seed=hello -y) # scriptable
cargo athena submit ./my-workflow -a seed=hello # -w omitted: the binary's root
Before anything is created, submit:
- type-checks the arguments against the function signature,
- confirms the binary tarball is uploaded,
- registers every WorkflowTemplate (asking y/N if any drifted),
- creates the Workflow and prints its name to stdout.
Transport auto-selects: with --argo-server / $ARGO_SERVER set it
uses the Argo Server REST API ($ARGO_TOKEN for auth); otherwise it
uses your kubeconfig (EKS / GKE / AKS exec plugins all work).
Flags:
-w TEMPLATE/--workflow- which template to submit (default: the binary’s root).<crate>-<fn>kebab or the short<fn>form.-a name=value(repeatable) /--input-file F- workflow arguments.-n NS/--namespace- target namespace.--service-account SA- override[defaults].service_account.--node-selector k=v(repeatable) - root-scoped, applies to every pod.--priority N- workflow priority (int32); higher = scheduled first when the controller hits its parallelism limit.--argo-server URL- use Argo Server REST instead of Kubernetes API.--insecure-skip-tls-verify- skip TLS verification talking to the Argo Server.-y/--yes- skip every y/N prompt.--update- re-apply all WorkflowTemplates.--skip-binary-check- don’t verify the tarball is uploaded.
publish
Cross-compiles a static-musl binary, packages it as a .tar.gz, and
uploads it to the artifact repository in athena.toml.
cargo athena publish --package my-crate
Requires the Zig cross toolchain: cargo install cargo-zigbuild and
zig. publish checks for both up
front and tells you what’s missing.
S3 credentials come from AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
(plus AWS_SESSION_TOKEN if you use it), or from instance-role
identity (EC2 IMDS / ECS task role / IRSA). The shared
~/.aws/credentials file is not read.
Flags:
--target T(repeatable) - override theathena.tomltarget matrix.--tarball F- uploadFverbatim; skip the build (build-once / upload-many).--allow-dirty/--dev-tag [T]/-y- the version gates (see Versioning). They resolve the tag baked into the binary and the S3 key.--print- dry run: resolve and print the destination key (and the resolved tag), no build or upload.AWS_ENDPOINT_URLenv var - override the endpoint for this upload only (port-forward / public-vs-in-cluster split).
build
The package-only variant of publish. Cross-compiles and writes the
.tar.gz locally without uploading - useful for CI artifacts or
inspection.
cargo athena build --package my-crate
cargo athena build --package my-crate --print # just resolve + print the key
Same flags as publish minus --tarball and the upload step, including
the --allow-dirty / --dev-tag version gates.
prune
Remove one deployed version of this binary’s
template-set: every WorkflowTemplate labelled cargo.athena/tag=<TAG>,
plus its {pkg}/<tag>/{bin}.tar.gz S3 binary. For cleaning up dev
iterations.
cargo athena prune dev-wip ./app # delete the dev-wip WTs + its S3 binary
cargo athena prune 0.5.0 ./app --keep-binary # WTs only; leave the tarball
<TAG> is a dev slot (dev-wip), a release tag (0-6-0), or a raw
semver (0.6.0, normalized). pkg / bin come from the probed binary,
and the selector always pins both package and tag, so it can never
fan out into a broad delete. Prints what it will remove and asks for
confirmation (-y to skip). Talks to the same transport as submit
(--argo-server / $ARGO_SERVER, else the kube API).
emulate
Runs one #[container] locally under docker or podman. -w picks the
container (default: the binary’s root template).
cargo athena emulate ./my-workflow -w transform -a data=hello -a factor=4
cargo athena emulate ./my-workflow -w fetch --input-file args.json
cargo athena emulate --build -w fetch # build from source for the run
The metadata comes from the [BINARY] you name; the run payload is the
deployed tarball pulled from S3 by default, so you smoke-test what’s
actually live. Arguments are type-checked against the real function
signature; missing or wrong-type values fail fast.
Not emulated: anything Kubernetes-specific. docker run has no
ServiceAccount, no RBAC, no nodeSelector. For those, use submit
on a real cluster.
Flags:
-w TEMPLATE/--workflow- the container to emulate (default: root).-a name=value(repeatable) /--input-file F- function arguments.--build- build a fresh local host-arch musl binary for the run instead of pulling the deployed tarball (source-only; omit[BINARY]).--tarball F- useFverbatim.--runtime docker|podman- autodetect by default (prefer docker).--skip-artifacts- bypass S3load/save_artifact!sync.
describe
Shows what one template expects: its signature, image, mounts, and a
copy-pasteable submit line. Works for either a #[container] or a
#[workflow].
cargo athena describe ./my-workflow # the root template
cargo athena describe ./my-workflow -w transform
cargo athena describe ./my-workflow --json # raw metadata, for scripts
ls
Lists the templates your workflow binary exposes.
cargo athena ls ./my-workflow # every template
cargo athena ls ./my-workflow --kind container # #[container]s only
cargo athena ls ./my-workflow --kind workflow # #[workflow]s only
cargo athena ls ./my-workflow --include-synthetic # + if/else internals
Selecting the binary
The consumer commands (emit, ls, describe, emulate, submit)
resolve their program in this order:
- the
[BINARY]positional - a path, or a bare name on$PATH(e.g. one installed withcargo install). No source needed. - otherwise a source build:
--manifest-path DIR(or the current crate), narrowed by-p/--packageand--bin(which fall back to[defaults].package/.bininathena.toml).
-w / --workflow names the template to act on and defaults to the
binary’s root (entrypoint!(Root)). build and publish always build
from source and take -p / --bin (never [BINARY]).
Working in this repo instead of an installed binary? Any
cargo athena <cmd>above becomescargo run -p cargo-athena --bin cargo-athena -- athena <cmd>.
athena.toml
athena.toml describes where the binary and artifacts live and a few
pod defaults. It is required by cargo athena and is
never read in-pod (everything it controls is baked into the emitted
YAML).
It is resolved in order: cargo athena -c FILE …, then the
ATHENA_CONFIG env var, then the nearest athena.toml walking up from
the cwd (like Cargo.toml), then a global
~/.config/cargo-athena/athena.toml (honoring $XDG_CONFIG_HOME). The
global fallback is for consumers of an already-published workflow:
submit / emit / ls / describe work from anywhere without a
per-repo config. First match wins (no merging); a repo-local
athena.toml always shadows the global one.
A complete example (the one the kind e2e uses):
[artifact_repository.s3]
endpoint = "minio.argo.svc.cluster.local:9000"
bucket = "athena-artifacts"
region = "us-east-1"
insecure = true # plain HTTP (e.g. MinIO)
access_key_secret = { name = "athena-s3", key = "accessKey" }
secret_key_secret = { name = "athena-s3", key = "secretKey" }
[bootstrap]
targets = ["x86_64-unknown-linux-musl", "aarch64-unknown-linux-musl"]
[defaults]
service_account = "default"
# package = "my-workflows" # so `cargo athena` needs no --package/-p
# bin = "app" # …or --bin, in a multi-bin crate
# namespace = "argo" # default namespace for `cargo athena submit`
[artifact_repository.s3]
The S3-compatible bucket holding both the binary tarball and every
load_artifact! / save_artifact! object. Emitted into each container
template as an Argo s3{} artifact source.
| Key | Meaning |
|---|---|
endpoint | S3 endpoint (host:port). |
bucket | Bucket name. |
region | S3 region. |
insecure | true for plain HTTP (e.g. local MinIO). |
access_key_secret / secret_key_secret | Kubernetes { name, key } secret selectors for credentials. |
The binary tarball’s object key is {crate}/<tag>/{bin}.tar.gz, where
<tag> is the build-time version tag - the kebab of
your crate’s semver on a clean release build (1.2.3 -> 1-2-3), or
dev-<slot> for a dev build. So a new release (or a new dev slot) lands
under its own key and never clobbers another version’s binary.
[bootstrap]
| Key | Meaning |
|---|---|
targets | The static-musl target triples to cross-compile. Each becomes app-<triple> in the tarball; in-pod the bootstrap picks the one matching uname. |
default_image | (optional) Default image when a #[container] doesn’t set its own image. Needs only POSIX sh and uname (distroless works). |
[defaults]
| Key | Meaning |
|---|---|
service_account | Pod ServiceAccount for every container, unless overridden by #[container(service_account = "…")]. |
package | (optional) Default cargo package the cargo athena subcommands drive, so you don’t repeat -p/--package. The flag wins. |
bin | (optional) Default cargo bin within it (multi-bin crates need this). The --bin flag wins. |
namespace | (optional) Default Kubernetes namespace for cargo athena submit. Precedence: -n/--namespace → $ARGO_NAMESPACE → this → default. |
The artifact bucket is the only coupling between an artifact’s producer and consumer (see
#[container]→ macro calls): they share a key, not a DAG edge.
Cookbook
Common, copy-pasteable patterns. The full rules behind each are on
the #[workflow] and #[container]
pages.
Data flow & shape
- Sequential vs. parallel
- Reuse a multi-step workflow as a building block
- Inline one step’s output into another
- Fan-out over a list
- Conditionals
- Pass only one field of a struct
- Force a sequential execution order
Artifacts & data sharing
- Share data between steps without a dependency
- Share data and keep a strict order
- Pass a large value between steps
Resilience & lifecycle
- Per-task hooks
- Retry with backoff
- Timeouts
- Whole-workflow cleanup
- Mutual exclusion across runs
- Throttle pods per workflow / per DAG
Pod placement & access
- Pin a single pod (image, service account, node)
- Pin every step in a workflow to specific nodes
- Tolerate node taints and steer with affinity
- Reach a podSpec field athena has no attr for
- Pull images from a private registry
- Inject an Argo built-in variable as a parameter
- Pull a Kubernetes Secret as an env var
- Reuse setup across containers
- Set up tracing once for every container
- Async
#[container]fns - Share a PVC across containers in a workflow
Sequential vs. parallel
Edges come from data, not statement order. Independent calls run in parallel; a shared input creates the dependency:
#[workflow]
fn pipeline() {
let a = ingest("src".to_string()); // a and b are independent:
let b = probe(); // they run in parallel
combine(a, b); // depends on BOTH, joins them
}
Need a strict order without a real data dependency? See Force a sequential execution order.
Reuse a multi-step workflow as a building block
A #[workflow] with a return type can be consumed exactly like a
container: the parent gets the workflow’s terminal output as a value.
Build pipelines out of smaller pipelines:
#[workflow]
fn sub(seed: String) -> String {
let f = fetch(seed);
transform(f, 7) // tail call is this workflow's return
}
#[workflow]
fn parent() {
let r = sub("seed".to_string());
publish(r);
}
Inline one step’s output into another
foo(bar()) runs bar as its own task and feeds its output straight
into foo. Shorthand for let x = bar(); foo(x);:
#[workflow]
fn pipeline() {
publish(transform(fetch("u".to_string()), 7));
}
Recursive: foo(bar(baz())) works the same way.
Fan-out over a list
#[workflow]
fn batch() {
let items = make_list(); // -> Vec<String>
let out = items.fan_out(|x| caps(x, "!".to_string()));
summarize(out); // out: Vec<String>
}
caps runs once per element of items; out is the aggregated
Vec, consumed like any output.
Conditionals
Real if / else / else if; a value-if selects the taken branch:
#[workflow]
fn gated() {
let n = decide("hello".to_string());
let chosen = if n > 3 { left(n) } else { right(n) }; // value-if
if n == 0 {
note("zero".to_string());
} else {
note(chosen);
}
}
Conditions are a closed grammar (== != < <= > >=, && || ! over
bindings / inputs / a.field / literals / nested calls).
Pass only one field of a struct
a.field (or a.field.sub) wires only that field to the next task:
#[derive(serde::Serialize, serde::Deserialize)]
struct Meta { id: String, n: i64 }
#[container] fn make_meta() -> Meta { Meta { id: "abc".into(), n: 7 } }
#[container] fn use_id(id: String) { println!("id={id}"); }
#[workflow]
fn pipeline() {
let m = make_meta();
use_id(m.id); // only `id` is wired through
}
Named fields only (no a.0 / a[i]). The compiler checks that the
field exists and matches the consumer’s type.
Force a sequential execution order
Two ways:
-
Thread a return value through. Any return value creates a real data dependency, so the consumer waits for the producer:
#[workflow] fn pipeline() { let token = step_a(); // -> String step_b(token); // can't start until step_a returns } -
Use
stepsmode. The default#[workflow]body is a DAG (edges from data deps). Addingstepsemits a sequential template instead, one statement per group:#[workflow(steps)] fn pipeline() { let p = prepare("seed".to_string()); finalize(p); }Same body, different shape on the wire.
Share data between steps without a dependency
A producer and consumer that share only an S3 key. No ordering, no DAG wiring:
#[container]
fn produce() { cargo_athena::save_artifact_str!("report", "hello"); }
#[container]
fn consume() {
let r = cargo_athena::load_artifact_str!("report");
println!("{r}");
}
A missing object is an error at runtime for the consumer.
Share data and keep a strict order
The recipe above has no ordering. To chain artifact-producing containers explicitly, bridge them with a return value: the artifact key stays a literal, and the return-value gives Argo the edge it needs:
#[container]
fn produce() -> String {
cargo_athena::save_artifact_str!("report", "hello");
"ok".to_string() // return value creates the edge
}
#[container]
fn consume(seq: String) {
let r = cargo_athena::load_artifact_str!("report");
println!("seq={seq}: {r}");
}
#[workflow]
fn pipeline() {
let token = produce();
consume(token); // edge: produce must finish first
}
Pass a large value between steps
A plain return goes inline through Argo, which is fine for small
JSON. For payloads measured in tens of KB or more, or any binary
blob, wrap the return in Artifact<T> and the value flows through
your bucket instead. Wiring is unchanged:
use cargo_athena::{container, workflow, Artifact};
#[container]
fn make_report() -> Artifact<Vec<u8>> {
Artifact::new(build_pdf()) // big binary
}
#[container]
fn ship(r: Artifact<Vec<u8>>) {
upload(r.into_inner());
}
#[workflow]
fn pipeline() {
let r = make_report();
ship(r); // looks like any binding-to-arg
}
When to pick which:
- Plain
Tfor small structured values - configuration, IDs, counts, modest JSON. Easy to see in the Argo UI. Artifact<T>for large or binary returns. No size cliff to worry about, but the value isn’t inspectable from the workflow status without downloading the object.save_artifact!/load_artifact!(the two recipes above) for fixed, known S3 keys where the producer and consumer can be wired separately or out of band.Artifact<T>is the DAG-wired sibling for the common one-producer/one-consumer case.
Per-task hooks
.continue_on / .on_success / .on_failure / .on_error /
.on_exit fire for one specific task:
#[workflow]
fn resilient() {
let raw = fetch("u".to_string()).continue_on(failed, error);
transform(raw, 9)
.on_failure(alarm)
.on_exit(cleanup); // runs when *this task* finishes
}
For a single hook that runs once when the whole workflow ends, see Whole-workflow cleanup below.
Retry with backoff
A flaky step retries itself:
#[container(retry(limit = 3, policy = "OnError", backoff = "30s"))]
fn fetch(url: String) -> String { /* … */ "ok".into() }
limit is required (unlimited for no cap); policy is one of
Always, OnFailure, OnError, OnTransientError; backoff is an
int (seconds) or a humantime string. Works on #[workflow] too. Full
field reference: #[container].
Timeouts
Three knobs for three scopes; stack as many as you need:
#[container(
timeout = "5m", // counts Pending time
pod_running_timeout = "2m", // only counts time Running
)]
fn long_step() { /* … */ }
#[workflow(active_deadline_if_root = "1h")] // whole-workflow cap (root-only)
fn pipeline() { /* … */ }
Full distinctions: Timeouts.
Whole-workflow cleanup
on_exit_if_root runs once when the workflow finishes, but only for
the workflow you actually submit:
#[workflow(on_exit_if_root = teardown)]
fn pipeline() { /* … */ }
When pipeline is run directly, teardown fires at the end (either
argo submit --from workflowtemplate/pipeline or cargo athena submit pipeline works). When pipeline is embedded as a sub-step
of a bigger run, its own on_exit_if_root stays inert; submit it
directly if you want the hook.
This is distinct from the per-task .on_exit(t) builder, which
always fires for that one task.
Mutual exclusion across runs
Block two runs of a workflow from racing each other, or serialize one expensive step within a run:
// Only one "deploy" workflow at a time across the namespace:
#[workflow(mutexes_if_root = [{ name = "deploy-" + env }])]
fn pipeline(env: String) { /* … */ }
// Serialize one expensive step; the rest of the DAG fans out normally:
#[container(mutexes = [{ name = "shard-" + shard }])]
fn writer(shard: String) { /* … */ }
Two tiers, picked by reach:
mutexes_if_rootis held for the whole submitted run. Root-only: inert when this WT is embedded as a sub. The standard “one of these workflows at a time” knob.mutexesis held just while the template’s node is running. Fires anywhere the template is invoked (root or nested).
Each entry is { name = …, namespace = … }; namespace is optional
(defaults to the workflow’s own). Both fields accept "lit" + arg
injection. Full details: #[workflow] Mutexes.
Throttle pods per workflow / per DAG
Cap how many pods Argo runs at once, either for the whole submitted run or just under one dag/steps template:
// Cap the whole run to 4 concurrent pods; cap THIS DAG to 2 of its
// own direct children at a time (nested templates don't count):
#[workflow(parallelism = 2, parallelism_if_root = 4)]
fn pipeline() { /* … */ }
Two tiers, picked by reach:
parallelism_if_rootcapsWorkflowSpec.parallelism, applied to every pod in the submitted run. Root-only: inert when this WT is embedded as a sub-workflow.parallelismcapsTemplate.parallelism, applied only to children scheduled DIRECTLY by this dag/steps. Pods from nested templates aren’t counted. Fires anywhere the template is invoked.
Both are literal i64 and must be > 0 (Argo’s CRD enforces
Minimum=1; the *int64 schema rejects substituted strings at
admission, so neither field supports argument injection).
Pin a single pod (image, service account, node)
Static, or with a container argument spliced in:
#[container(
image = "ghcr.io/acme/heavy:" + tag,
service_account = "athena-" + tenant + "-runner",
node_selector = { "kubernetes.io/arch" = "amd64",
"disktype" = profile.disk },
)]
fn heavy(tag: String, tenant: String, profile: Profile) -> String { tag }
Operands are an argument or a named struct field of one, and must
be String / &str / number. See
Parameter injection.
Pin every step in a workflow to specific nodes
#[workflow(
boundary_node_selector = { // literal-only
"kubernetes.io/arch" = "amd64",
},
node_selector_if_root = { // injection allowed
"tier" = "platform",
"env" = "prod-" + env,
},
)]
fn pipeline(env: String) { /* ... */ }
boundary_node_selectorcovers pods whose immediate enclosing dag/steps is this template. Does NOT cascade through nested sub-workflows. Literal only. If you want a value that depends on an argument, usenode_selector_if_root.node_selector_if_rootis the default for every pod in the submitted run that doesn’t have a tighter override. Root-only: inert when this WT is embedded as a sub. Values accept"lit" + arg/"lit" + arg.fieldinjection.
Tolerate node taints and steer with affinity
Most clusters taint GPU / spot / dedicated nodes; pods need tolerations to schedule there:
#[container(tolerations = [
{ key = "nvidia.com/gpu", operator = "Exists", effect = "NoSchedule" },
{ key = "spot", operator = "Equal", value = "true", effect = "NoSchedule" },
])]
fn train(input: String) { /* ... */ }
For “every pod gets these tolerations,” use the root-level version:
#[workflow(tolerations_if_root = [
{ key = "dedicated", operator = "Equal", value = "ml-team", effect = "NoSchedule" },
])]
fn pipeline() { /* ... */ }
Affinity is a deeply-nested K8s shape; athena keeps it as an opaque YAML/JSON string so you write what K8s already documents:
#[workflow(affinity_if_root = r#"
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-pool
operator: In
values: [gpu-a100]
"#)]
fn pipeline() { /* ... */ }
Embed {{workflow.parameters.X}} substitutions verbatim if you need
dynamic values at root scope. Same goes for the container-level
affinity = "...".
The boundary tier covers the case “all pods that live inside this
specific dag inherit these scheduling constraints” (boundary_tolerations
and boundary_affinity, mirroring boundary_node_selector). Pods that
set their own override the inheritance; pods that don’t pick up the
boundary’s values. Literal only at this tier: use the matching
*_if_root form for values that depend on an argument.
Reach a podSpec field athena doesn’t have an attr for
pod_spec_patch = "<json|yaml>" is the universal escape hatch: Argo
strategic-merges the patch onto the rendered pod just before
submission. Use it for any K8s field cargo-athena doesn’t lift to a
first-class attr (CPU/memory limits, init containers, sidecars,
fsGroup, runtimeClassName, …).
// Per-container patch (pins this template's pod resources).
#[container(pod_spec_patch = r#"{
"containers":[{"name":"main","resources":{
"limits":{"cpu":"500m","memory":"512Mi"},
"requests":{"cpu":"100m","memory":"128Mi"}
}}]
}"#)]
fn heavy(input: String) { /* ... */ }
// Whole-workflow patch (every pod in the run).
#[workflow(pod_spec_patch_if_root = r#"{
"terminationGracePeriodSeconds":120
}"#)]
fn pipeline() { heavy("x".to_string()); }
The string accepts the usual "lit" + arg injection grammar, e.g.
pod_spec_patch = r#"{"containers":[{"name":"main","resources":{"limits":{"cpu":""# + cpu + r#""}}}]}"#.
Injection works at both the container and the root tier.
athena does NOT validate the patch shape: that is the trade-off for “any field.” Argo and the K8s API reject malformed input at submit / admission time.
Pull images from a private registry
Bind one or more imagePullSecrets (Secret names in the workflow’s
namespace) to every pod in the run:
#[workflow(image_pull_secrets_if_root = ["regcred", "harborcred"])]
fn pipeline() { build(); deploy(); }
K8s / Argo expose this only at workflow scope; if you need a
per-container override (rare), reach for pod_spec_patch.
Inject an Argo built-in variable as a parameter
#[inject("<argo expression>")] on a function arg fills it from Argo’s
substitution at pod-creation, bypassing inputs.parameters entirely.
#[container]
fn smart_retry(
payload: String, // normal caller arg
#[inject("{{retries}}")] attempt: i64, // bare numeric
#[inject("\"{{pod.name}}\"")] pod_name: String, // quoted string
) {
println!("attempt {attempt} on pod {pod_name} with payload={payload}");
}
#[workflow]
fn pipeline() {
// The workflow body passes only the caller-visible param. The two
// inject args are filled by Argo in the pod.
smart_retry("hello".to_string());
}
The macro does NOT validate the expression: it’s piped to Argo verbatim. You own:
- The variable’s scope.
{{retries}}only resolves inside aretry(...)strategy;{{tasks.X.outputs.Y}}only resolves inside a DAG context. - JSON wrapping. Numeric /
booltypes want a bare expression ({{retries}}→3);Stringtypes want explicit quotes ("\"{{workflow.name}}\""→"wf-abc").
Wrong wrapping or out-of-scope refs fail the run with a clear “deserialize container input” message: a useful signal that the value didn’t substitute.
Pull a Kubernetes Secret as an env var
secret!("secret-name", "key") declares a Secret env on the
container and reads it back at runtime as a String. secret_opt!
is the no-panic flavour (returns Option<String>):
#[container]
fn fetch(url: String) -> String {
let token = cargo_athena::secret!("api-tokens", "api");
let trace = cargo_athena::secret_opt!("debug-creds", "trace");
/* … use them … */
String::new()
}
secret_opt! skips the env when the secret/key is missing, instead
of failing pod start.
Reuse setup across containers
A #[fragment] is just a normal Rust function that runs inside the
calling container. It can take arguments, do real work, and return a
value, exactly like any helper. Its only superpower: every host! /
artifact / secret! declaration it makes is added to each container
that transitively calls it.
So you can wrap “open a database connection” once and hand the connection back to every container that needs one:
#[fragment]
fn open_db() -> DbHandle {
let user = cargo_athena::secret!("db-creds", "user");
let pass = cargo_athena::secret!("db-creds", "password");
let ca = cargo_athena::host!("/secrets/db"); // host dir -> &Path
DbHandle::connect(&user, &pass, &ca)
}
#[container]
fn migrate() {
let db = open_db(); // mounts + env land on this pod
db.run_migrations();
}
#[container]
fn nightly_audit() {
let db = open_db(); // …and this one
let n = db.flag_anomalies();
println!("flagged {n}");
}
Every container that calls open_db() automatically gets the
database Secret and the host mount wired into its pod. The values
come back through open_db()’s return, so a caller never names an
env var or mount path itself, and doesn’t have to know what’s inside
the fragment.
Set up tracing (or any pod-only init) once for every container
Your workflow binary runs in two worlds (see Core
Concepts), so
tracing_subscriber::fmt().init() in main() would fire on every
local cargo athena call too: harmless for stdout logging, but a
footgun for OTLP exporters or anything that dials out or costs money.
Gate it with cargo_athena::is_container_run() so the setup only
runs in-pod:
fn main() {
// None for `cargo athena emit` / `ls` / etc.; Some(_) in-pod.
// The returned guard (if any) drops at end of main(), so any
// tracing/OTLP flush you stick on Drop fires after the body.
let _otel = cargo_athena::is_container_run().then(|| {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
OtelFlushGuard::new()
});
cargo_athena::entrypoint!(MyRoot);
}
The pattern works for anything you only want in-pod: a Prometheus
push gateway, a Sentry init, an audit-log open. Per-container span
scoping (one tracing::info_span! per body) isn’t covered by this
pattern – if you need it, open an issue.
Async #[container] fns
Mark a container async fn and the macro wraps the body in a
current-thread tokio runtime. Enable the tokio feature on
cargo-athena to opt in; tokio is re-exported:
// Cargo.toml: cargo-athena = { …, features = ["tokio"] }
#[container]
async fn fetch(url: String) -> String {
cargo_athena::tokio::time::sleep(std::time::Duration::from_millis(10)).await;
format!("data-from:{url}")
}
#[workflow] bodies are statically analyzed, so
#[workflow] async fn is a compile error.
Share a PVC across containers in a workflow
Declare a PVC as a unit struct, then mount it with pvc!(Type)
inside any #[container] / #[fragment]. Two flavors, picked by
who owns the PVC’s lifetime:
// Per-workflow-run scratch space. Argo creates the PVC at workflow
// start and deletes it at workflow end (Argo's
// `WorkflowSpec.volumeClaimTemplates`).
#[cargo_athena::ephemeral_pvc(
size = "10Gi",
access_modes = ["ReadWriteMany"],
)]
pub struct BuildCache;
// Reference to a pre-existing PVC (managed out of band). athena
// never creates or deletes it.
#[cargo_athena::external_pvc(claim_name = "shared-data-pvc", read_only = true)]
pub struct SharedData;
#[container]
fn build() {
let cache: &Path = cargo_athena::pvc!(BuildCache);
std::fs::write(cache.join("output.bin"), b"hello").unwrap();
}
#[container]
fn analyze() {
// Same type → same PVC. Two pods sharing a `ReadWriteMany`
// ephemeral see each other's files.
let cache: &Path = cargo_athena::pvc!(BuildCache);
let bytes = std::fs::read(cache.join("output.bin")).unwrap();
println!("read {} bytes", bytes.len());
}
#[workflow]
fn pipeline() {
let _ = build();
analyze();
}
Two consumers sharing the same #[ephemeral_pvc] concurrently
need ReadWriteMany. ReadWriteOnce is fine when only one pod
ever mounts it at a time, but a parallel fan-out over RWO will
fail the second pod’s volume attach.
The mount path is opaque (/athena/pvcs/<hash>) and stable across
emit and run; always use the returned &'static Path value and
never hard-code the path.
v1 caveat: every #[ephemeral_pvc] linked into your binary
lands on every emitted WorkflowTemplate’s volumeClaimTemplates.
Argo creates ALL of them per run, even if the submitted workflow
doesn’t reach them. Keep one workflow per binary and define each
#[ephemeral_pvc] near its consumer to avoid cross-workflow
PVC churn. See the #[container] reference for details.
Pitfalls
- Fan-out a value to two consumers needs
.clone(). The body is faithful Rust; each consumer gets its own copy of the upstream value, so the explicit clone is correct. - Workflow bodies are strict. Loops,
match, and arbitrary expressions are compile errors by design, so a step is never silently dropped.if/else/else if, nested calls, and the builder /fan_outchain are supported. - Any string value is safe.
t("no")works and aString"7"stays a string, not a number, because every parameter value is JSON-encoded. (An argument name that reads as a YAML bool is a separate matter and is rejected at compile time; see Troubleshooting.)
Hitting an actual error? See Troubleshooting.
Publishing from CI
Cross-compiling a static-musl multi-arch binary, packaging it, and uploading it
to object storage is a production publish flow you should not have to re-derive.
cargo-athena ships a composite GitHub Action, athena-publish, that wraps the
whole thing: it installs the Zig cross-compiler and the rustup musl targets,
installs the pinned cargo athena CLI, and runs cargo athena publish against
your athena.toml.
It works with any S3-compatible store: the access key, secret key, and endpoint
are all you provide, and the endpoint (which usually already lives in your
athena.toml) is what selects the provider.
Quick start
Add one workflow to your repo. This validates the cross-compile on every PR
(build, no credentials needed) and uploads on release tags (publish):
# .github/workflows/publish.yml
name: publish
on:
push:
tags: ["v*"]
pull_request:
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: mostlymaxi/cargo-athena/.github/actions/athena-publish@v0.6.1
with:
command: ${{ github.event_name == 'pull_request' && 'build' || 'publish' }}
package: my-workflow
access-key: ${{ secrets.S3_ACCESS_KEY }}
secret-key: ${{ secrets.S3_SECRET_KEY }}
# endpoint: https://<account>.r2.cloudflarestorage.com # only to override athena.toml
Pin the action to a release tag (@v0.6.1). The tag fixes the cargo athena
CLI version too, so there is nothing else to keep in sync.
Credentials
Pass your storage credentials as repository secrets:
| Input | Purpose |
|---|---|
access-key | S3 access key (any S3-compatible provider). |
secret-key | S3 secret key. |
endpoint | Optional. Overrides the athena.toml endpoint for the CI uploader only (split-horizon). Usually unnecessary. |
session-token | Optional. AWS-STS temporary-credential token. |
Do not put credentials in athena.toml (its
*_secret entries are Kubernetes Secret references for the in-cluster side).
The action reads creds only from these inputs. Omit access-key/secret-key
entirely to use the runner’s ambient identity (instance role / IRSA).
Inputs
| Input | Default | Notes |
|---|---|---|
command | publish | publish (cross-compile + upload) or build (package only, no creds). |
package | sole package | Cargo package to build (-p). |
bin | default bin | Cargo bin within the package. |
targets | from athena.toml | Comma/space list; overrides the [bootstrap].targets matrix. |
config | discovered | Path to athena.toml if not found by walking up. |
working-directory | . | Must be your Cargo workspace root (where ./target lives). |
tarball | none | publish only: upload a prebuilt tarball verbatim (build-once / upload-many). |
rust-toolchain | stable | Toolchain to install. |
doctor | true | Run cargo athena doctor preflight first. |
Output: s3-uri is the s3://bucket/key the tarball was uploaded to (empty for
command: build).
Notes
- Run from the workspace root.
cargo athena buildstages the tarball relative to the current directory while cargo emits to the workspacetarget/, soworking-directorymust be the workspace root. In a monorepo, keep the default and passpackage. - Pre-installed CLI wins. If
cargo-athenais already onPATH(for example via Nix), the action uses it and skips the install. - Caching. The CLI install is cached across runs, and your crate build is
cached with
Swatinem/rust-cache, so warm runs are fast.
Prefer doing this by hand, or want the underlying flags? See the CLI page.
Troubleshooting
Common errors and what they actually mean.
Compile errors
“expected type, found function”
error[E0573]: expected type, found function `my_fragment`
You called a #[fragment] or a regular Rust function from a
#[workflow] body. Workflow bodies only accept template calls
(#[workflow] or #[container]), nothing else. Either:
- Move the helper into a
#[container]body (where regular code is allowed), or - Convert the helper to a
#[fragment]and call it from a#[container](fragments run inside the calling pod).
“unsupported statement in a #[workflow]” / shape-specific variants
The macro emits a targeted message per shape with a hint at what is supported:
for-> “For per-element parallel work uselist.fan_out(|x| step(x)); for sequential work, thread a return value through.”while/loop-> “A #[workflow] body is read once to build the DAG, not iterated at runtime. Move the loop inside a #[container] body, or use.fan_outfor parallelism.”match-> “For exclusive branches useif/else if/else(supported).”.method()-> “The lowered chains are.clone()/.to_owned()on args,.fan_out,.continue_on,.on_success/.on_failure/.on_error/.on_exit/.hook_if.”- macros -> “A macro call here would be dropped from the DAG. If you need pod resources (
host!,secret!,load_artifact!,save_artifact!), declare them inside a #[container] body.”
Supported shapes are:
let x = template(args);
template(args);
if cond { ... } else { ... }
binding.fan_out(|x| template(x, …));
binding.continue_on(...) // and the other per-task hooks
“the trait Injectable is not implemented for …”
You used image = "repo:" + arg (or similar) where arg is a type
that doesn’t round-trip through serde_json to a raw scalar. Only
String, &str, and the numeric primitives are injectable. Use a
literal, or change the argument’s type.
Argument name like no / yes / on / true rejected
error: argument name "no" would be reinterpreted by YAML 1.1 as a bool
Argo’s YAML parser silently turns bare y/yes/n/no/on/off/
true/false/null/~ (any case) into bools or null, which would
mis-type a parameter named that. Rename the argument.
Runtime errors (from cargo athena ...)
“binary tarball not found at s3://…”
You ran cargo athena submit but haven’t run cargo athena publish
on the current version, or the upload was cleaned up. Either:
- Run
cargo athena publishfirst, then re-submit, or - Pass
--skip-binary-checkif you’re sure (e.g. testing against a fixture upload).
The S3 object key embeds the build-time version tag
({crate}/<tag>/{bin}.tar.gz) - the kebab of the semver on a release
build (1.2.3 -> 1-2-3), or dev-<slot> on a dev build. If a pod
can’t pull its binary, confirm publish and submit resolved the same
tag: a source-build submit with no ATHENA_VERSION_TAG re-derives the
tag from git, which won’t match a binary you published with --dev-tag.
Export ATHENA_VERSION_TAG (or submit the prebuilt binary) so both agree.
“could not list templates” / “could not get template metadata”
cargo athena: could not get template list from the workflow crate
cargo athena can’t find a workflow binary to act on. Either:
- Pass a built/installed binary as the positional argument
(
cargo athena ls ./my-workflow), or - Build from source: run from inside your workflow crate, pass
-p <package>(and--bin <name>for a multi-bin crate) or--manifest-path <dir>, or - Set
[defaults].package(and optionally.bin) inathena.toml.
If you get a compile error from the user binary, that’s now streamed to your terminal: scroll up.
S3 credentials / endpoint issues
cargo-athena reads the standard AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY, and (if used) AWS_SESSION_TOKEN env vars,
plus the ambient cloud identity (EC2 IMDS, ECS task role, IRSA web
identity).
Not read: ~/.aws/credentials and AWS_PROFILE. The S3 client is
object_store, not the AWS SDK, and the shared-config file is
unsupported. If you rely on a profile, export the credentials before
running:
eval "$(aws configure export-credentials --profile prod --format env)"
cargo athena publish
For a custom endpoint (MinIO, self-hosted), athena.toml sets the
endpoint pods use. To override it just for the upload from your
machine (e.g. port-forwarded MinIO from outside the cluster), set
AWS_ENDPOINT_URL for that one command. emit still bakes the
config’s endpoint into the YAML, so pods always hit the in-cluster
address.
In-cluster errors
Workflow Pending forever, no events
If your Argo controller is configured managedNamespace=<X>, it
only watches that namespace. A Workflow submitted to the wrong
namespace stays Pending with no phase, no events, no errors.
Check the controller config:
kubectl -n argo get configmap workflow-controller-configmap -o yaml | grep namespace
Submit with -n <X> or set [defaults].namespace in athena.toml.
Pods 403 on workflowtaskresults
pods "xxx" is forbidden: User "system:serviceaccount:..." cannot create
resource "workflowtaskresults" in API group "argoproj.io"
The workflow ServiceAccount needs the Argo executor role binding.
namespace-install.yaml omits it for the default SA, so a fresh
install in a namespace other than argo ends up with every step
failing 403.
Bind the executor role to the SA used by your pods (see the project’s
scripts/deploy.sh for the kubectl invocation we use in CI).
“configmaps is forbidden: … cannot create resource configmaps”
task '...' errored: configmaps is forbidden: User
"system:serviceaccount:argo:argo" cannot create resource "configmaps"
in API group "" in the namespace "argo"
A task’s substituted arguments crossed the 128 KB threshold and Argo
3.7+ tried to stage them in a per-pod ConfigMap (PR #15265). The
upstream namespace-install.yaml grants the controller SA only
read access on configmaps. Add create (and update/patch/delete
for resilience) to the controller’s Role - scripts/deploy.sh does
this with Role/RoleBinding athena-argo-configmaps.
On Argo 3.6 the offload path doesn’t exist at all; arguments over the
kernel exec ARG_MAX (~128 KB) fail with argument list too long,
and the only fix is using Artifact<T> for the large value (see
Pass a large value between steps).
“failed to resolve {{tasks.X.outputs.*}}” on Argo ≤ 3.5
cargo-athena emits one WorkflowTemplate per template, wired via
templateRef. Argo 3.5 and older can’t resolve cross-template task
output references at submit time, so any multi-step workflow fails
instantly with this message.
Fixed in Argo 3.6. The emitted YAML is correct and passes 3.6 / 3.7 / 4.0 unchanged. cargo-athena does not support Argo ≤ 3.5; do not try to “fix” by inlining.
Pod CrashLoopBackOff, “exec format error” or “no such file”
The injected bootstrap couldn’t pick a matching binary for the node’s
architecture. Check that the targets list in athena.toml
[bootstrap] includes a triple matching
each node where pods can land. The default
(x86_64-unknown-linux-musl, aarch64-unknown-linux-musl) covers
most clusters; if you only build one and your scheduler picks the
other, you’ll see this.
The image needs only POSIX sh and uname. If you’re using a
non-standard base, docker run --rm -it your/image sh -c 'uname -m'
to confirm.
Other gotchas
cargo athena emit shows old YAML after a code change
The cargo invocation behind emit uses cargo run, so an incremental
rebuild should pick up your changes. If it doesn’t, you’re probably
running from outside the workflow crate against a stale binary; pass
-p <package> or run from the crate root.
“duplicate volume name” or DNS-1123 errors on host!
host!("/p") mounts under /athena/mounts/<hash> to avoid clobbering
the container fs (host!("/") would otherwise overlay the host root).
The <hash> is derived deterministically from the path and is wide
enough that collisions are not practical, so duplicate Volume names
(which Kubernetes rejects) are rare; if you hit one, two host!
literals collided.
If you need a specific in-container mount path, use
#[container(host_mount = [...])] instead. See
#[container] macro calls for the full
host! rules.
Still stuck? The #[workflow] and
#[container] references cover every attribute and
shape, and the Cookbook has worked examples for the
common tasks.
Testing
cargo-athena gives you a fast inner loop without a cluster, plus guard rails before code reaches one. Four levels, fastest to most thorough.
1. Unit-test the container body
A #[container] body is ordinary Rust, so call it from a regular
#[test]:
#[container]
fn summarize(data: String, top_n: i64) -> String {
format!("top-{top_n}:{data}")
}
#[test]
fn summarize_picks_top_n() {
assert_eq!(summarize("hello".into(), 3), "top-3:hello");
}
No harness, no infrastructure. This is the right test for “does my business logic do the right thing on these inputs?”.
2. Run the step like Argo would (emulate)
For “does the step run correctly in its container?”,
cargo athena emulate is the fast inner loop. It runs one
#[container] under docker or podman with the same image, the same
injected bootstrap, the same parameter env, the /athena scratch
dir, host! binds, and S3 artifact ports.
cargo athena emulate ./my-workflow -w transform -a data=hi -a factor=2
By default it pulls the deployed binary from S3, so what you
emulate is what’s actually live. --build packages a fresh local
musl binary instead; --tarball F uses one verbatim.
Arguments are type-checked against the real function signature before anything launches, so wrong types fail fast with a clear message instead of a serde panic inside the pod.
Not emulated: anything Kubernetes-specific (ServiceAccount, RBAC,
nodeSelector, podSpecPatch). See the CLI page for
the full list and flags.
3. Snapshot the emitted YAML
cargo athena emit is deterministic, so snapshot it and fail CI on
unintended changes:
# Commit a baseline
cargo athena emit --package my-workflows > tests/golden/emit.yaml
# In CI, fail loud on any diff
diff <(cargo athena emit --package my-workflows) tests/golden/emit.yaml
This catches DAG / wiring / parameter regressions before a cluster
ever sees them. cargo-athena’s own test suite does this across a
broad “all features” fixture (see
examples/smoke/tests/golden/) plus
trybuild compile-fail tests pinning the strict body grammar and
macro guards.
4. End-to-end on real Argo
For full conformance, submit to a real Argo + S3:
cargo athena publish
cargo athena submit my-crate-pipeline -a seed=hi
Wait for the run, assert it Succeeded. The project’s own GitHub
Actions matrix does exactly this on every push to main against
three Argo versions (4.0.5 / 3.7.14 / 3.6.19) and the badges in
Supported Argo Versions are that live result.
To reproduce the cluster locally:
scripts/deploy.sh && scripts/e2e-test.sh && scripts/teardown.sh
You need a Docker or Podman daemon. nix develop provides
kind / argo / mc if you use Nix.
Examples
Six examples live in the repo’s examples/
directory, each illustrating something different.
getting-started
The smallest end-to-end pipeline: three containers in a fetch -> summarize -> publish chain. Mirrors the
Getting Started walkthrough on the docs site.
basic
The minimum viable shape: two #[workflow] functions, one
#[container], one #[fragment]. Good for seeing the macros without
surrounding detail.
smoke
The “all features” fixture used by the project’s own goldens.
Exercises every macro attribute (retry, timeouts, mutexes,
nodeSelector, on_exit, hooks), the conditional shapes
(value-if, statement-if/else-if/else), fan_out over a list, named
struct-field forwarding (a.field), host! mounts, S3 artifact ports,
secret!, fragments, nested calls, and steps mode.
If you want one place to see a pattern in action, this is it.
Library source · Golden YAML per feature
importing
Cross-module and cross-crate composition: this crate depends on
smoke and references one of its templates from a new #[workflow]
in another crate. Demonstrates that templates compose through normal
Rust name resolution.
e2e
The crate the project’s GitHub Actions matrix actually submits to a
real Argo + MinIO on every push to main. It’s the live conformance
test for every supported Argo version (4.0.5 / 3.7.14 / 3.6.19).
If you want to see a workflow that’s verified-running on real Argo, this is the one.
tracing
A two-container greet -> shout pipeline that emits tracing::info!
records. The subscriber is installed once in main(), gated on
cargo_athena::is_container_run() so it fires only in-pod, never on a
local cargo athena emit / ls / submit. The runnable companion to
the cookbook’s
Set up tracing
recipe.
Supported Argo Versions
Every push to main submits the real examples/e2e workflow to a live
Argo + MinIO per version and asserts it Succeeded. The support
matrix is therefore not a claim - it is a continuously verified result.
| Argo | Support |
|---|---|
| v4.0.5 | maintained (latest minor) |
| v3.7.14 | maintained (n-1 minor) |
| v3.6.19 | minimum supported (EOL, hard-gated) |
Argo Workflows maintains the two most recent minors; cargo-athena tracks
that plus the minimum that still works. All three are blocking CI
jobs (no continue-on-error).
Why ≤ 3.5 is unsupported
cargo-athena emits one WorkflowTemplate per template, wired via
templateRef. Argo’s submit-time validator before 3.6 cannot resolve
{{tasks.X.outputs.*}} across a templateRef boundary, so any
multi-step workflow fails instantly with
failed to resolve {{tasks.a.outputs.…}}.
This was fixed in Argo 3.6: the emitted YAML is correct and passes 3.6/3.7/4.0 unchanged. Older versions may still work for trivial cases; use at your own risk.
What’s degraded on 3.6
The args-offload feature (Argo PR #15265) shipped in 3.7. On 3.6 the
controller does not stage large arguments via ConfigMap, so any task
whose substituted args[] cross the kernel exec ARG_MAX (~128 KB
combined) fails with argument list too long. Use Artifact<T> for
any parameter that may exceed that threshold if you need to run on
3.6. 3.7 and 4.0 are unaffected: the controller stages large args
automatically; cargo-athena’s runtime reads the offloaded value back
transparently.
Live badges
GitHub has no per-matrix-job badge, so each matrix job publishes its pass/fail to a gist and the README renders shields.io endpoint badges from it - the badges at the top of the README are that live e2e result.