* Add ADRs for bundle packing options and Go tool directive implementation * Add ADR for dual-mode bundle binary supporting msgpack-over-IPC coordinator protocol * Rename ExecutableRuntimeCoordinator to ExecutableCoordinator across documentation and codebase * Refactor bundle spec to use self-contained executable with embedded metadata and source * CI: Fix the link to other docs * Reflect self-contain binary spec in ADR 0003 * Resolve cross-compile in packer with two-build introspection The packer's manifest-population step execs the freshly built binary with --dump-bundle-spec. On cross-builds (e.g., darwin/arm64 host packing for linux/amd64) the target binary is not runnable on the host, so a single go-build pipeline cannot introspect it. The same issue applies to --executable, where the user may hand the packer a pre-built cross-target binary that the host cannot exec. Specify in ADR 0002 that the packer treats introspection and target-build as separate concerns. The packer attempts to exec the candidate introspection binary and, on "exec format error", builds a host-arch sidecar from the positional package, execs that for --dump-bundle-spec, and appends the resulting footer to the target artifact (whether that artifact came from the internal go build or from --executable). The Go build cache amortises the sidecar to a link step when host arch is already involved, so there is no overhead when no cross-compile is needed and no host-side runner (Rosetta, qemu-user-static) is required for the cross case. If --executable is given without a positional package and the supplied binary is not host-runnable, the packer errors with a message asking for the source package so the sidecar can be built. Add a one-line note to ADR 0001 Option D's cons pointing to ADR 0002 so the trade-off is visible alongside the other options. * Scope Windows out of v1 for self-contained bundle format The footer-after-EOF layout assumes signing tools hash the entire file. That holds on Linux (dm-verity) and macOS (codesign) but not on Windows: PE Authenticode stores its signature in the certificate table referenced from the Optional Header, and Microsoft's EnableCertPaddingCheck hardening (MS13-098) rejects extra bytes after the signature. Appending the bundle footer in either order relative to signtool produces a binary that strict-mode verification rejects, so the current ADR cannot honestly claim Windows support. Remove the .exe defaults and the Authenticode reference from the build-pipeline and out-of-scope sections, and add Windows as an explicit non-goal for v1 with a note that supporting it would require a different layout (e.g. a PE resource section) tracked separately. The packer should refuse GOOS=windows builds in v1 with a clear error rather than silently producing an unsignable artefact. * Drop "statically-linked" qualifier from native-SDK claim The footer-after-EOF technique works because the OS loader stops at the format-defined end of image (ELF section/segment extents, Mach-O LC_SEGMENT extents), not because the binary is statically linked. Rust and C++ default to dynamic glibc on Linux and remain single-file artefacts that take the footer cleanly. Replace "statically-linked native executable" with "single-file native executable" so the reasoning matches the property the technique actually depends on, and fix the parallel claim in the context paragraph above. * Add binary_sha256 to footer; demote OS code-signing to deployment hardening The previous draft of ADR 0004 leaned on OS code-signing (codesign / Authenticode / dm-verity) for bundle tamper detection. That framing was honest only for macOS, and even there it conflated authenticity (signed by a trusted identity) with integrity (file is byte-identical to what the packer produced). Airflow's threat model treats bundles_folder as Deployment-Manager-controlled, so the bundle format only needs to provide integrity against truncation, partial writes, and naive tampering; authenticity is a deployment-time concern that Deployment Managers can layer on top with whatever signing flow matches their platform. Expand the trailer from 32 to 64 bytes to carry binary_sha256 (32-byte SHA-256 over bytes [0, source_start), the binary region only — the hash field is itself in the trailer and cannot cover itself). The scanner verifies the hash at discovery and caches the result by (path, inode, mtime, size) so the runtime does not re-hash on every exec. Mismatch is treated identically to a failed magic check: the scanner skips the file with a clear error. Reframe the code-sign rule: the bundle format neither requires nor prevents OS code-signing. Deployment Managers who want codesign/rcodesign on macOS or fs-verity/IMA on Linux apply it after the footer append so the signature covers the trailer; those who do not (the common Linux deployment) get tamper detection from the embedded hash alone. Narrow the Windows non-goal accordingly. The footer-after-EOF layout runs fine on PE; only Authenticode code-signing is incompatible because MS13-098 rejects extra bytes past the signature. Windows becomes a supported bundle target again (.exe default output restored); Authenticode-signed Windows bundles remain out of scope for v1 and would require a different layout (PE resource section) tracked separately. Note the small verification-to-execve TOCTOU window under "What this costs"; acceptable for v1 given the threat model. * Replace "fully general" hedge with the actual go-build-flag rule The Option A bullet ended with "kept inside the standalone-packer shape so the SDK does not own a fully general go build wrapper", which describes what the packer is not without saying what it is. The real rule is positive: the packer never interprets go build flags; arbitrary flags pass through verbatim after the -- separator, which keeps the packer's flag surface small and stable as go build evolves. State the rule directly. * Resolve dispatch-matrix overlap between go-plugin and error rows The matrix marked the AIRFLOW_BUNDLE_MAGIC_COOKIE row as "(default)" while keeping an "Otherwise -> error" fallthrough row, which made it unclear which row fires when no cookie is set. The actual rule is ordered match: rows 1-4 each require a specific positive trigger (metadata flag, spec-dump flag, comm/logs pair, magic cookie env var), and row 5 fires only when none of those conditions hold. Drop the misleading "(default)" annotation, number the rows so the ordering is explicit, and reword the fallthrough row to make the precedence visible at a glance. * Note that panic recovery is pkg/worker behaviour, not coordinator-only The task-execution diagram shows "(panic recovered -> 'failed')", which a reviewer could plausibly read as a coordinator-mode invention. Recovery is actually pkg/worker.Worker.runTask's existing defer recover() that calls reportStateFailed on panic (runner.go:295-311); both go-plugin mode and coordinator mode reuse the same Worker, so the behaviour is identical between the two modes. Add a paragraph next to the diagram so the property is read as shared rather than divergent. * Make ADRs up to date * Address phani's comment and static check errors * Rename --bundle-metadata flag as --airflow-metadata
18 KiB
4. Self-contained executable bundle (footer-embedded source and metadata)
Date: 2026-05-04
Status
Accepted. Supersedes the ZIP-archive container portion of
ADR 0001 and the ZIP output sketched
in ADR 0002. The
packer mechanism (Option A standalone packer + Option D introspection
contract + Option H tool directive) is unchanged; only the artefact
the packer writes is changed.
Context
ADR 0001 / ADR 0002 picked a ZIP archive as the bundle container,
following the executable provider's
task-sdk/docs/executable-bundle-spec.rst.
A conforming bundle in that earlier design was bundle.zip with three
required entries: airflow-metadata.yaml, the primary DAG source file,
and the compiled executable. (This ADR is what changed the container to
the footer format the spec now documents.)
That layout has three properties we want to preserve:
- Discovery without execution. The scanner must be able to read
dag_id/task_idand the SDK language/version from a bundle on disk without running the binary. ADR 0002 already enforces this —airflow-go-packruns the binary once at build time, captures its--airflow-metadataoutput into the manifest, and the scanner reads the manifest at deploy time. - Source available for the UI. The Airflow UI's source-view
panel needs to render the DAG file. The current spec ships it as a
verbatim ZIP entry referenced by the manifest's
sourcefield. - Single deployment unit. Drop one file in the coordinator's
executables_rootand the scanner picks it up.
What the ZIP container costs us:
- Two artefacts in flight.
go buildproduces a binary; the packer wraps it into a ZIP. Anything that touches the binary after it is wrapped (re-strip, re-sign, swap-in a debug build) drifts from the manifest unless the wrapping is redone. The wrapping step is cheap but the drift mode is real. - A second container format on the consumer side. The scanner
must open archives, find members by name, and materialise the
executable into a transient cache before the runtime can exec it.
That is
archive/zipon the Python side plus a per-bundle cache directory. - Inspection requires a different tool than running.
unzipto inspect, then run; or run, thenunzipto debug. Two muscle memories.
Native-executable SDKs (Go, Rust, C++, Zig) all produce a single-file binary as their primary build output. The binary itself is already the only thing that has to land on the worker host to run a task. The manifest and the source file are small data the scanner needs but the runtime doesn't. Both can ride along in a footer appended to the binary, with the binary remaining a runnable executable.
This is the same pattern self-extracting installers, goreleaser-style
self-update images, and embedded-asset binaries already use: append
data after the OS-recognised binary structure, leave a fixed-size
trailer at the very end so a reader can locate the data, and validate
with a magic value.
The user-facing claim becomes "the executable is the bundle." A bundle directory looks like:
/opt/airflow/executable-bundles/
├── example
├── pipeline
└── analytics
(Filenames follow OS conventions: no extension on Linux/macOS, .exe
on Windows. The scanner identifies bundles by the trailer's magic,
not by the filename.)
Decision
Replace the bundle's ZIP container with a footer appended to the compiled executable. The executable's normal byte content is unchanged and it remains directly runnable; the footer is data that follows the last byte the OS loader cares about.
Footer layout
A bundle file is laid out as:
+---------------------------------+
| <native executable: ELF/Mach-O/PE, |
| including any code-signing structures> |
+---------------------------------+ <- end of "binary" region
| source bytes (variable length) | raw root source file, UTF-8,
| | length = source_len; MAY be 0
+---------------------------------+
| metadata bytes (variable length)| airflow-metadata.yaml content,
| | UTF-8, length = metadata_len
+---------------------------------+
| trailer (64 bytes, little-endian fixed layout): |
| bytes 0..3 source_len u32 |
| bytes 4..7 metadata_len u32 |
| bytes 8..11 footer_ver u32 (= 1) |
| bytes 12..43 binary_sha256 32 bytes (SHA-256 of binary region) |
| bytes 44..55 reserved 12 bytes, zero |
| bytes 56..63 magic 8 bytes ASCII "AFBNDL01" |
+---------------------------------+ <- EOF
AFBNDL01 is 0x41 0x46 0x42 0x4E 0x44 0x4C 0x30 0x31. The two
trailing ASCII digits are the footer-format version, repeated for human
inspection (tail -c 8 ./mybundle | xxd); the binary footer_ver
field is the source of truth for parsing.
binary_sha256 is computed over the binary region only — bytes
[0, source_start) — because the hash field is itself in the trailer
and cannot cover the bytes it occupies. It provides integrity (the
binary region has not been truncated, corrupted, or naively edited
between packing and exec), not authenticity (see "Out of scope" for
how authenticity layers on top).
Reader algorithm:
- Open the file. Seek to
EOF - 64. Read 64 bytes. - Compare bytes 56..63 against
AFBNDL01. If different, the file is not a bundle; the scanner ignores it. - Parse
footer_ver. If unknown, fail with a versioning error. - Compute
metadata_start = filesize - 64 - metadata_lenandsource_start = metadata_start - source_len. - Read
metadata_lenbytes frommetadata_startfor the manifest. - Read
source_lenbytes fromsource_startfor the source view. Ifsource_len == 0, no source is embedded; the UI falls back to "(source not available)". - Validate that
source_start >= 0and that the implied "binary region" (bytes[0, source_start)) is non-empty. - Compute SHA-256 over
[0, source_start)and compare tobinary_sha256. Mismatch is a hard failure: the scanner logs and skips the file, the same way it would on a magic-check failure. The result is cached by(path, inode, mtime, size)so the runtime does not re-hash on every exec; a cache miss (file replaced, mtime bumped) triggers re-verification.
Ordering note: source comes before metadata so a future
footer_ver can introduce extra trailing blobs (e.g. signed
checksums, compressed deps) by extending the trailer rather than
inserting between existing blobs.
Manifest schema changes
The manifest content is the same YAML as today, with two field-level changes that follow from the footer container:
- Drop
executable. The binary is the file; there is no archive-relative path to record. - Redefine
sourceas a display filename, not a path. The source bytes live in the footer; the manifest'ssourcefield carries the original filename (e.g.example.go) so the UI can show it as a filename in the source-view panel and pick a syntax-highlighting mode from the extension.
Everything else (airflow_bundle_metadata_version, sdk.language,
sdk.version, sdk.supervisor_schema_version, dags, the
open-additivity rule for unknown keys) is unchanged.
Build pipeline
The packer's behaviour from ADR 0002 changes only at the final write step:
- Resolve target package, locate the file with
func main(). (No change.) - Run
go build [forwarded flags] -o <out> <pkg>. (No change.) - Exec the freshly built binary with
--airflow-metadatato obtain the manifest. (No change.) - New: read the source file's bytes; serialise the manifest to
YAML; compute
binary_sha256 = SHA-256(<out>)over the entire on-disk file as it stands after step 2 (which is the binary region — nothing has been appended yet); assemble the trailer with the resulting digest; append<source><metadata><trailer>to<out>. - Default output path becomes
<bundleName>(or<bundleName>.exeon Windows), not<bundleName>.zip.
Ordering against post-build steps:
- Strip: must run before append. Stripping a file that already has a footer either leaves the footer intact (most strip implementations stop at the OS-defined end of the binary) or truncates it; do not rely on either.
- Code-sign (optional): the bundle format does not require OS
code-signing. The embedded
binary_sha256provides integrity, and Airflow's threat model treatsexecutables_rootas Deployment-Manager-controlled — authenticity is a deployment-time concern, not a bundle-format one. Deployment Managers who want OS-enforced load gating (macOScodesign/rcodesign, Linux fs-verity, IMA) layer it on top of the bundle: sign after the footer append so the signature covers the trailer along with the binary region. Windows Authenticode is incompatible with this layout (see "Out of scope") but does not block Windows as a bundle target. - Compressors (UPX, etc.): unsupported. UPX rewrites the file end to end, destroying the trailer. Bundle binaries should not be compressor-wrapped; this matches typical production deployment practice.
Determinism: the footer is byte-identical for byte-identical inputs
(source bytes, manifest YAML, layout), so a deterministic go build
plus a deterministic manifest serialisation produces a byte-identical
bundle file. We canonicalise the manifest as sorted-key YAML at write
time to avoid map-order non-determinism on the Go side.
Cross-language scope
The bundle spec is language-agnostic by design. Every native-SDK
language we currently target (Go, Rust, C++, Zig) emits a single-file
native executable; appending a fixed-format footer is a few lines of
code in each. The technique works because the OS loader stops at the
format-defined end of image (ELF section/segment extents, Mach-O
LC_SEGMENT extents) — it does not depend on the binary being
statically linked, so dynamically-linked Rust or C++ artefacts on
Linux take the footer cleanly. The footer layout above is the
contract every SDK packer implements; the consumer-side scanner reads
it identically regardless of source language.
Interpreted languages without a single binary artefact are out of scope for the executable provider and therefore for this ADR.
Consumer-side changes
The scanner currently iterates *.zip in executables_root and opens
each as an archive. It now iterates all regular files, reads the
last 64 bytes of each, and treats files whose magic matches as
bundles. Files without the magic are silently ignored (so a stray
README in the directory does not fail the scan). Files with matching
magic are SHA-256-verified per the reader algorithm; a mismatch
demotes the file back to "ignored, with an error log."
The runtime no longer has to materialise an executable from an
archive. It execs the bundle file directly, which removes the
transient cache directory and the chmod-after-extract step from
the spec. The integrity check is run by the scanner at discovery
time and cached by (path, inode, mtime, size), so the exec hot
path does not re-hash.
Consequences
What this buys
- One artefact. No
.zipwrapper around a binary; the binary is the deployment unit.cp ./mybundle /opt/airflow/executable-bundles/is the deploy workflow. - No drift between binary and manifest. They are produced and committed in the same step and physically attached.
- Atomic deploy. A partially written file fails the magic or
binary_sha256check; the scanner skips it cleanly instead of seeing half a manifest. - Integrity built in.
binary_sha256catches truncation, in-flight corruption, and naive tampering without any external signing infrastructure. Authenticity (signed-by-trusted-identity) is a separate concern that Deployment Managers can layer on top. - Smaller consumer surface. No
archive/zipdependency, no per-bundle cache directory, no chmod-after-extract path, no external-attributes handling for the executable bit. - Simpler runtime. Exec the file directly.
What this costs
- Inspection needs a tool. With ZIP,
unzip -p bundle.zip airflow-metadata.yamlworked from any shell. With the footer format, ops needs a small CLI (go tool airflow-go-pack inspect ./mybundleor equivalent) to dump the manifest and source. Cheap to implement; the obligation is "ship it alongside the packer." - Build pipeline ordering matters. Strip-then-append-then-sign is the only correct order. Documented in the packer and in this ADR; failure modes (stripped trailer, signature over the wrong bytes) are loud (magic check fails, signature verification fails) rather than silent.
- Compressor incompatibility. UPX and similar are not supported for bundle binaries. Acceptable; production deployments do not typically compress executables this way.
- Magic-collision handling. A non-bundle file in
executables_rootwhose last 8 bytes happen to beAFBNDL01would briefly look like a bundle. Probability is negligible for a fixed 8-byte ASCII string, and the subsequentfooter_ver/ bounds-check / SHA-256 verification rejects the collision deterministically. - TOCTOU between verify and
execve. The scanner verifiesbinary_sha256at discovery time; the runtime later execs the same path. In between, a write to the file would not be caught until the cache key (inode,mtime,size) changes and the scanner re-verifies. Acceptable for v1 because the threat model treatsexecutables_rootas Deployment-Manager-controlled; Deployment Managers who need stronger guarantees apply OS-enforced load gating (fs-verity, IMA, codesign) on top. - Footer format is now a wire format the SDK has to keep stable.
footer_ver = 1is the only currently defined value; future versions append fields after the version field but before the reserved region, or use the reserved region. Older readers reject unknownfooter_verrather than guessing.
Out of scope
- Authenticated bundle signatures. The footer provides integrity
(
binary_sha256), not authenticity. Any deployment-time signature flow that wants to attest "this bundle was produced by entity X" (macOScodesign/rcodesign, Linux IMA, fs-verity policy) is layered on top of the bundle file and is out of scope of this ADR. A signed footer field could be added in a futurefooter_verif the SDK ever needs to ship its own trust anchor, but doing so now would force key-management decisions Airflow does not currently need to make. - Authenticode-signed Windows bundles. The footer-after-EOF
layout runs fine on Windows for execution — the OS loader stops
at the PE size-of-image, identical to ELF/Mach-O behaviour, and
binary_sha256covers the binary region the same way. What does not work is layering Authenticode on top: PE Authenticode stores its signature in the certificate table referenced from the Optional Header, and Microsoft'sEnableCertPaddingCheckhardening (MS13-098) rejects extra bytes past the signature, sosigntoolagainst an appended bundle (in either order) produces a binary that strict-mode verification rejects. Deployment Managers who need Authenticode-signed bundles on Windows must use a different on-disk layout (storing source and manifest in a dedicated PE resource section so they are inside the signed image); that layout is tracked separately. The bundle format itself supports Windows as a target. - Multiple source files. Only the root file (the file containing
func main()) is embedded. DAGs split across multiple source files keep the rest of their sources outside the bundle; the UI source-view shows only the entry file. Revisit if user feedback requests broader source visibility. - Compression of the source/metadata blobs. Both are tiny (kilobytes) next to the binary; deflating them adds reader complexity for no measurable space win.
Implementation notes
- The append step is
os.OpenFile(out, O_RDWR|O_APPEND, 0)plus three writes (source, metadata, trailer) followed byClose. No mmap needed. The packer streams the binary region throughsha256.New()once before the append to computebinary_sha256; the digest is the only state needed across the read and write phases. - The executable bit on the output file is set by
go builditself. The append step preserves it (we write through, not truncate). - The packer's existing reproducibility guarantees (sorted entries,
fixed mtimes) reduce to "write a deterministic YAML manifest"; the
ZIP-specific concerns (entry ordering, entry mtimes, external
attributes) go away.
binary_sha256is deterministic by construction. - The Python-side scanner's bundle-detection helper lives next to
BundleScanner; it reads 64 bytes per file, parses the trailer, then streams the binary region throughhashlib.sha256to verifybinary_sha256. The verification result is cached by(path, inode, mtime, size)so the runtime exec path does not re-hash. Keep the helper tolerant of trailing whitespace or short files (anything< 64bytes is not a bundle).