Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 needs sh and uname.

How it fits together

You writecargo-athena produces
#[workflow] fnan Argo WorkflowTemplate (a DAG, or sequential steps)
#[container] fnan Argo WorkflowTemplate (a container step) that runs your real Rust
#[fragment] fna 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 default cli feature 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-features and skip init. See athena.toml for 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-publish action. 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 / submit walks the template closure from your entrypoint and prints one WorkflowTemplate per 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.

ArgEffect
name = "my-name"Override the Argo template name. Default <crate>-<fn> (kebab).
stepsEmit 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 = tWhole-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); policyAlways / 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. strategyOnPodCompletion / 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 = NTemplate.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 = NRoot-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" + arg value 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" + arg injection 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

FormSemantics
literal "s", 7, truea static parameter value
a #[workflow] input paramthe workflow input, forwarded
a prior let bindingthe 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.subone 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 returns Artifact<T> and the consumer accepts Artifact<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.

  • a must be a prior let binding 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, an a.field of one, a literal, or a nested template call. Anything outside this grammar (method calls, arithmetic, casts) is a targeted compile error.
  • Value-if requires an else and both arms producing the same type.
  • Bindings created inside an arm are not visible after the if. Use the value-if form 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:

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.

ArgEffect
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 = trueK8s 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 = tWhole-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); policyAlways / 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. strategyOnPodCompletion / 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:

AttributeWhat it boundsClock starts
timeoutthis stepwhen the node is created (includes Pending)
pod_running_timeoutthis podwhen the pod is Running
active_deadline_if_rootthe whole submitted runat 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; no a.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 / &str or a number (i64, f64, …). Anything else is a compile error, because only those round-trip to a raw scalar value.
  • node_selector keys are always literal (a dynamic label key would be a foot-gun).
  • name is the static template identity and on_exit_if_root is 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.

MacroEffectRuntime 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 keyVec<u8>
load_artifact_str!("key")same, as textString
save_artifact!("key", bytes)S3 output at the given object keywrites impl AsRef<[u8]>
save_artifact_str!("key", text)same, as textwrites impl AsRef<str>
secret!("name", "key")a K8s Secret env on this containerString (panics if unset)
secret_opt!("name", "key")same, optionalOption<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:

  1. Multi-workflow binaries (multiple #[workflow] fns in one bin with disjoint PVCs): submitting workflow A creates B’s PVCs too.
  2. 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:

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 (or master) → the tag is your crate’s semver, kebab’d (0.6.0myapp-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, so build / publish hard-fail without it.
    • off a release branch - a warning + confirmation (-y / --yes to skip, for CI).

    --dev-tag names the dev slot: bare --dev-tag uses the short commit (myapp-train-dev-a1b2c3d, a new slot per commit), while --dev-tag foo gives 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 - prefill athena.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 runnable Workflow so kubectl 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:

  1. type-checks the arguments against the function signature,
  2. confirms the binary tarball is uploaded,
  3. registers every WorkflowTemplate (asking y/N if any drifted),
  4. 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 the athena.toml target matrix.
  • --tarball F - upload F verbatim; 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_URL env 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 - use F verbatim.
  • --runtime docker|podman - autodetect by default (prefer docker).
  • --skip-artifacts - bypass S3 load/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:

  1. the [BINARY] positional - a path, or a bare name on $PATH (e.g. one installed with cargo install). No source needed.
  2. otherwise a source build: --manifest-path DIR (or the current crate), narrowed by -p / --package and --bin (which fall back to [defaults].package / .bin in athena.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 becomes cargo 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.

KeyMeaning
endpointS3 endpoint (host:port).
bucketBucket name.
regionS3 region.
insecuretrue for plain HTTP (e.g. local MinIO).
access_key_secret / secret_key_secretKubernetes { 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]

KeyMeaning
targetsThe 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]

KeyMeaning
service_accountPod 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

Artifacts & data sharing

Resilience & lifecycle

Pod placement & access

Pitfalls


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:

  1. 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
    }
  2. Use steps mode. The default #[workflow] body is a DAG (edges from data deps). Adding steps emits 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 T for 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_root is 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.
  • mutexes is 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_root caps WorkflowSpec.parallelism, applied to every pod in the submitted run. Root-only: inert when this WT is embedded as a sub-workflow.
  • parallelism caps Template.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_selector covers 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, use node_selector_if_root.
  • node_selector_if_root is 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.field injection.

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 a retry(...) strategy; {{tasks.X.outputs.Y}} only resolves inside a DAG context.
  • JSON wrapping. Numeric / bool types want a bare expression ({{retries}}3); String types 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_out chain are supported.
  • Any string value is safe. t("no") works and a String "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:

InputPurpose
access-keyS3 access key (any S3-compatible provider).
secret-keyS3 secret key.
endpointOptional. Overrides the athena.toml endpoint for the CI uploader only (split-horizon). Usually unnecessary.
session-tokenOptional. 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

InputDefaultNotes
commandpublishpublish (cross-compile + upload) or build (package only, no creds).
packagesole packageCargo package to build (-p).
bindefault binCargo bin within the package.
targetsfrom athena.tomlComma/space list; overrides the [bootstrap].targets matrix.
configdiscoveredPath to athena.toml if not found by walking up.
working-directory.Must be your Cargo workspace root (where ./target lives).
tarballnonepublish only: upload a prebuilt tarball verbatim (build-once / upload-many).
rust-toolchainstableToolchain to install.
doctortrueRun 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 build stages the tarball relative to the current directory while cargo emits to the workspace target/, so working-directory must be the workspace root. In a monorepo, keep the default and pass package.
  • Pre-installed CLI wins. If cargo-athena is already on PATH (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 use list.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_out for parallelism.”
  • match -> “For exclusive branches use if/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 publish first, then re-submit, or
  • Pass --skip-binary-check if 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) in athena.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.

Source · Emitted YAML

basic

The minimum viable shape: two #[workflow] functions, one #[container], one #[fragment]. Good for seeing the macros without surrounding detail.

Source

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.

Source

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.

Source

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.

Source

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.

ArgoSupport
v4.0.5maintained (latest minor)
v3.7.14maintained (n-1 minor)
v3.6.19minimum 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.