| .. Licensed to the Apache Software Foundation (ASF) under one | |
| or more contributor license agreements. See the NOTICE file | |
| distributed with this work for additional information | |
| regarding copyright ownership. The ASF licenses this file | |
| to you under the Apache License, Version 2.0 (the | |
| "License"); you may not use this file except in compliance | |
| with the License. You may obtain a copy of the License at | |
| .. http://www.apache.org/licenses/LICENSE-2.0 | |
| .. Unless required by applicable law or agreed to in writing, | |
| software distributed under the License is distributed on an | |
| "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| KIND, either express or implied. See the License for the | |
| specific language governing permissions and limitations | |
| under the License. | |
| Executable Bundle Spec | |
| ====================== | |
| This document specifies the on-disk format of a build artifact produced by an | |
| Airflow native-executable SDK (Go, Rust, C++, Zig, ...) and consumed by | |
| :class:`~airflow.sdk.coordinators.executable.ExecutableCoordinator` | |
| at deployment time. | |
| The goal is a single, language-agnostic *bundle* shape so that scheduler, | |
| worker, and UI behave identically regardless of which compiled SDK produced | |
| the DAG. | |
| Bundle-spec version: ``1.0``. | |
| Container | |
| --------- | |
| A bundle is **the compiled executable itself, with a fixed-format footer | |
| appended after the binary's normal end-of-file**. The executable remains | |
| directly runnable; the footer is data that follows the last byte the OS | |
| loader cares about and is invisible to ``exec()``. There is no enclosing | |
| archive. | |
| A bundle file therefore has three regions, in order from offset 0: | |
| 1. The native executable (ELF / Mach-O / PE), including any code-signing | |
| structures the platform appends. | |
| 2. The embedded source files, each verbatim (UTF-8), back to back. MAY have length 0. | |
| The metadata indexes them. See :ref:`the source region <bundle-source-region>`. | |
| 3. The build-time manifest (``airflow-metadata.yaml`` content, UTF-8). | |
| The file ends with a fixed 64-byte trailer that locates regions (2) and (3), | |
| carries an integrity hash of the binary region, and identifies the file as a | |
| bundle. See :ref:`bundle-trailer-layout`. | |
| A bundle file has no file extension, and the Dag processor does not treat a | |
| file with an extension as a bundle. The scanner identifies bundles by the | |
| trailer's magic. | |
| The complete bundle file regions are: | |
| .. code-block:: text | |
| [0, source_start) native binary (must be non-empty) | |
| [source_start, metadata_start) embedded source files (may be zero length) | |
| [metadata_start, file_size-64) build-time manifest | |
| [file_size-64, file_size) 64-byte trailer | |
| where ``metadata_start = file_size - 64 - metadata_len`` and | |
| ``source_start = metadata_start - source_len``. | |
| .. _bundle-source-region: | |
| The source region holds the source files of the Dags the bundle defines, one entry per file. Files | |
| are concatenated with no separator, and the ``sources`` list in the manifest gives each file's | |
| ``path``, ``offset`` and ``length`` within the region and its ``sha256``. A file's ``offset`` is | |
| relative to ``source_start``, so the first file has offset ``0``. | |
| Reference Implementation | |
| ------------------------ | |
| Below is a simple implementation to append the trailer with Python as a | |
| reference when building your own packer. A language SDK is encouraged to | |
| integrate trailer-packing into the build process to streamline the experience | |
| for SDK users. Go SDK's ``airflow-go-pack`` is a good example. | |
| .. code-block:: python | |
| #!/usr/bin/env python3 | |
| import hashlib | |
| import pathlib | |
| import shutil | |
| import struct | |
| BINARY = pathlib.Path(...) # Path to the compiled executable. | |
| OUTPUT = pathlib.Path(...) # Where to put the processed executable. | |
| SOURCES = [b"..."] # Source files to embed, in the order the manifest lists them. | |
| METADATA = b"..." # UTF-8-encoded YAML metadata. | |
| # SHA-256 covers the binary region only: bytes [0, source_start). | |
| binary_sha256 = hashlib.sha256(BINARY.read_bytes()).digest() | |
| trailer = struct.pack( | |
| "<III 32s 12s 8s", | |
| sum(map(len, SOURCES)), # source_len | |
| len(METADATA), # metadata_len | |
| 1, # footer_ver | |
| binary_sha256, | |
| bytes(12), # reserved | |
| b"AFBNDL01", # magic | |
| ) | |
| assert len(trailer) == 64 | |
| shutil.copy(BINARY, OUTPUT) | |
| with OUTPUT.open("ab") as fh: | |
| fh.writelines(SOURCES) # Embedded source region. | |
| fh.write(METADATA) # Metadata region. | |
| fh.write(trailer) | |
| OUTPUT.chmod(0o755) | |
| .. _bundle-trailer-layout: | |
| Trailer Layout | |
| -------------- | |
| The last 64 bytes of a conforming bundle are the trailer. All multi-byte | |
| integers are little-endian. | |
| .. code-block:: text | |
| bytes 0..3 source_len uint32 length of the source region in bytes | |
| bytes 4..7 metadata_len uint32 length of the metadata region in bytes | |
| bytes 8..11 footer_ver uint32 currently 1 | |
| bytes 12..43 binary_sha256 32 bytes SHA-256 of the binary region [0, source_start) | |
| bytes 44..55 reserved 12 bytes MUST be zero | |
| bytes 56..63 magic 8 bytes ASCII "AFBNDL01" | |
| The magic is the byte sequence ``0x41 0x46 0x42 0x4E 0x44 0x4C 0x30 0x31`` | |
| (``"AFBNDL01"``). The trailing ``01`` is the footer-format version repeated | |
| in ASCII so a human can identify a bundle at a glance | |
| (``tail -c 8 ./mybundle | xxd``); the binary ``footer_ver`` field is the | |
| authoritative source of truth for parsing. | |
| ``binary_sha256`` is the SHA-256 digest computed over the **binary region | |
| only** — bytes ``[0, source_start)``. The hash field sits inside the trailer | |
| and therefore cannot cover the bytes it occupies; it provides *integrity* | |
| (the binary region has not been truncated, corrupted, or naively edited | |
| between packing and exec) rather than *authenticity* | |
| (see :ref:`bundle-code-signing` for how authenticity layers on top). | |
| Reader algorithm: | |
| 1. Open the file. Seek to ``EOF - 64``. Read 64 bytes. | |
| 2. Compare bytes ``56..63`` against ``"AFBNDL01"``. If different, the file | |
| is not a bundle; the scanner MUST ignore it. | |
| 3. Parse ``footer_ver``. If unknown, fail with a versioning error. | |
| 4. Compute ``metadata_start = filesize - 64 - metadata_len`` and | |
| ``source_start = metadata_start - source_len``. | |
| 5. Validate ``source_start >= 0`` and that the implied binary region | |
| (``[0, source_start)``) is non-empty. | |
| 6. Compute SHA-256 over the binary region ``[0, source_start)`` and compare | |
| to ``binary_sha256``. Mismatch is a hard failure handled identically to | |
| a magic-check failure: the scanner logs and skips the file. The result | |
| MAY be 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. | |
| 7. Read ``metadata_len`` bytes from ``metadata_start`` for the manifest. | |
| 8. Read the source files through the manifest's ``sources`` list. For each entry, check that | |
| ``offset`` and ``length`` are non-negative integers and that ``offset + length <= source_len``. | |
| To read a file, read ``length`` bytes from ``source_start + offset`` and compare their SHA-256 to | |
| ``sha256``. A duplicate ``path`` or a digest mismatch is an error. Without a ``sources`` key, no | |
| source is embedded, and the UI shows a notice in place of the source. | |
| A Dag's source file is the ``dag_source_paths`` entry for its ``dag_id``. A Dag with no entry, | |
| such as one built dynamically, shows ``entrypoint_path``. A Dag owned by another language, such as | |
| the Python Dag that a bundle's task handlers run for, shows its own source and not an embedded | |
| file. ``entrypoint_path`` and every ``dag_source_paths`` value MUST be one of the ``sources`` | |
| paths. | |
| Source comes *before* metadata so a future ``footer_ver`` MAY introduce | |
| additional trailing blobs (e.g. signed checksums, compressed deps) by | |
| extending the trailer rather than inserting between existing blobs. | |
| .. _bundle-metadata-schema: | |
| ``airflow-metadata.yaml`` schema | |
| -------------------------------- | |
| The metadata region carries the same YAML manifest documented previously, | |
| produced at build time from a static scan of the DAG source. A | |
| machine-readable JSON Schema is published at | |
| :download:`airflow-metadata.schema.json` for use by build tooling, validators, | |
| and editors. | |
| .. code-block:: yaml | |
| airflow_bundle_metadata_version: "1.0" | |
| sdk: | |
| language: go | |
| version: "0.1.0" | |
| supervisor_schema_version: "2026-06-16" | |
| entrypoint_path: example/bundle/main.go | |
| dag_source_paths: | |
| example_dag: example/bundle/main.go | |
| another_dag: example/bundle/dags/another.go | |
| sources: | |
| - path: example/bundle/main.go | |
| offset: 0 | |
| length: 1532 | |
| sha256: 0f3a...e91c | |
| - path: example/bundle/dags/another.go | |
| offset: 1532 | |
| length: 811 | |
| sha256: 7b21...04d8 | |
| dags: | |
| example_dag: | |
| tasks: | |
| - extract | |
| - transform | |
| - load | |
| another_dag: | |
| tasks: | |
| - run | |
| Top-level keys: | |
| ``airflow_bundle_metadata_version`` (string, required) | |
| The bundle-spec version this manifest conforms to. Currently ``"1.0"``. | |
| ``sdk`` (mapping, required) | |
| Identifies the SDK that produced the bundle. | |
| - ``language`` (string, required): lower-case source-language identifier | |
| (e.g. ``go``, ``rust``, ``cpp``, ``zig``). | |
| - ``version`` (string, required): SDK version used at build time. | |
| - ``supervisor_schema_version`` (string, required): dated AIP-72 | |
| supervisor wire-schema version the bundle was compiled against, in | |
| ``YYYY-MM-DD`` format (e.g. ``"2026-06-16"``). The coordinator passes | |
| this value to the supervisor so it can downgrade outbound messages / | |
| upgrade inbound messages to a shape the bundle understands. The value | |
| MUST resolve against the supervisor's schema bundle; the coordinator | |
| validates it lazily when matching a bundle to a task at | |
| task-execution time, and an unknown version causes that bundle to be | |
| skipped. | |
| ``entrypoint_path`` (string, optional) | |
| Path of the entrypoint source file, such as the Go ``main`` package file. It MUST be one of | |
| the ``sources`` paths. The Airflow UI shows it for a Dag that ``dag_source_paths`` does not map. | |
| ``dag_source_paths`` (mapping, optional) | |
| Mapping of ``dag_id`` to the path of the file that defines the Dag. Every value MUST be one of | |
| the ``sources`` paths. | |
| ``sources`` (list, optional) | |
| The embedded source files, in the order they appear in the source region. A bundle without it | |
| embeds no source. Each entry has: | |
| - ``path`` (string, required): the file's path as the author knows it, such as | |
| ``example/bundle/main.go``. Paths MUST be unique. The Airflow UI picks a syntax-highlighting | |
| mode from the extension. | |
| - ``offset`` (integer, required): the file's start, in bytes from ``source_start``. | |
| - ``length`` (integer, required): the file's length in bytes. | |
| - ``sha256`` (string, required): the lower-case hexadecimal SHA-256 of the file's bytes. | |
| ``dags`` (mapping, required) | |
| Mapping of ``dag_id`` to a *DAG entry*, for each Python Dag with stub TaskHandler whose tasks the | |
| bundle runs. The scanner uses these keys to match a task-execution request to the bundle that | |
| owns it. The mapping MAY be empty, for example when the bundle only declares Dag definitions, | |
| because the Dag processor finds those by running the binary. | |
| DAG entry fields: | |
| ``tasks`` (list of strings, required) | |
| Static list of ``task_id``\ s declared in the DAG. Empty lists are | |
| permitted but discouraged. | |
| Unrecognized top-level or DAG-entry keys MUST be ignored by the consumer so | |
| that future SDK versions can extend the manifest without breaking older | |
| runtimes. | |
| Examples | |
| -------- | |
| Go bundle:: | |
| example | |
| ├── ELF/Mach-O/PE executable | |
| ├── source region: example/bundle/main.go, example/bundle/dags/another.go | |
| ├── metadata region: airflow-metadata.yaml (entrypoint_path, dag_source_paths, sources) | |
| └── trailer (64 B): lengths + binary_sha256 + AFBNDL01 magic | |
| Rust bundle:: | |
| pipeline | |
| ├── ELF/Mach-O/PE executable | |
| ├── source region: src/main.rs | |
| ├── metadata region: airflow-metadata.yaml (entrypoint_path: src/main.rs) | |
| └── trailer (64 B): lengths + binary_sha256 + AFBNDL01 magic | |
| The bundle is one file. ``./example`` runs the binary; the appended data | |
| is invisible to ``exec()``. | |
| Build Pipeline Ordering | |
| ----------------------- | |
| The footer is appended after the executable is otherwise complete. Producers | |
| that perform additional post-build steps MUST observe the following order: | |
| - **Strip** debug symbols *before* appending the footer. Strip | |
| implementations operate on the binary's defined end and either leave | |
| trailing data intact or truncate it; do not rely on either behaviour. | |
| - **Compute binary_sha256** over the on-disk bytes *as they stand | |
| immediately before the append*. At that moment the whole file is the | |
| binary region; nothing has been written past its OS-defined end yet, so | |
| the digest matches what the reader will recompute over | |
| ``[0, source_start)`` after the append. | |
| - **Append** ``<sources><metadata><trailer>`` in a single write so a | |
| partially written file fails the magic or hash check rather than | |
| appearing as a half-valid bundle. | |
| .. _bundle-code-signing: | |
| Code Signing | |
| ~~~~~~~~~~~~ | |
| The bundle format itself does not require OS-level code signing. | |
| ``binary_sha256`` provides integrity against truncation, in-flight | |
| corruption, and naive tampering, and Airflow's threat model treats the Dag | |
| bundle holding the bundles as Deployment-Manager-controlled, so | |
| *authenticity* (signed by a trusted identity) is a deployment-time concern | |
| rather than a bundle-format one. | |
| **Compressors** such as UPX are NOT supported. They rewrite the file | |
| end-to-end, destroying both the trailer and the hash invariant. | |
| Determinism: the trailer is byte-identical for byte-identical inputs, so a | |
| deterministic build plus a canonical (sorted-key) manifest serialization | |
| yields a byte-identical bundle file (and therefore a stable | |
| ``binary_sha256``). | |
| Deployment Layout | |
| ----------------- | |
| Bundle files are placed **as-is** in the Dag bundle named by the | |
| ``task_handler_bundle_name`` kwarg on the | |
| :class:`~airflow.sdk.coordinators.executable.ExecutableCoordinator` entry | |
| under ``[sdk] coordinators`` (or, when it is unset, in the task's own Dag | |
| bundle). The scanner walks the Dag bundle **recursively** and considers every | |
| regular file. For each candidate it reads the last 64 bytes and treats files | |
| whose magic matches ``"AFBNDL01"`` as bundles. Matched files are then | |
| SHA-256-verified per the reader algorithm; a mismatch demotes the file back to | |
| "ignored, with an error log." Files without the magic are silently ignored, so | |
| non-bundle files (READMEs, dotfiles) MAY share the Dag bundle without | |
| interfering with the scan. A file that matches the selected bundle's | |
| ``dag_id`` is marked executable before it is run, so an object-store Dag | |
| bundle (which has no concept of file permissions, and so cannot preserve the | |
| execute bit a build produced) works too. | |
| :: | |
| /opt/airflow/go-task-handlers/ | |
| ├── example | |
| ├── team-a/ | |
| │ └── pipeline | |
| └── analytics | |
| At task-execution time the runtime execs the bundle file directly with the | |
| coordinator arguments (``--comm=<addr>`` / ``--logs=<addr>``). No extraction, | |
| no transient cache directory, no chmod-after-extract step is required: the | |
| file is already a runnable executable with the appropriate permission bits | |
| preserved by the build pipeline. The integrity check runs at scan/discovery | |
| time and is cached by ``(path, inode, mtime, size)``, so the exec hot path | |
| does not re-hash. | |
| The compiled executable MUST honor the SDK coordinator protocol — | |
| ``--comm=<host:port>`` / ``--logs=<host:port>`` socket-based IPC. | |
| See :class:`~airflow.sdk.coordinators.executable.ExecutableCoordinator` | |
| for the consumer-side coordinator. | |
| Inspection | |
| ---------- | |
| Because the bundle is a single executable rather than an archive, | |
| inspecting the embedded source and manifest requires a small CLI rather | |
| than an off-the-shelf ``unzip``. The Go SDK's ``airflow-go-pack`` tool | |
| provides an ``inspect`` subcommand that dumps both regions; equivalent | |
| helpers are expected from each language's packer. | |
| Compatibility and Versioning | |
| ---------------------------- | |
| - The current bundle-spec format version is ``1.0`` | |
| (``airflow_bundle_metadata_version``); the current trailer format version is | |
| ``1`` (``footer_ver = 1``). | |
| - Backward-incompatible bundle-spec changes increment the major component | |
| of ``airflow_bundle_metadata_version`` and are gated behind an explicit opt-in | |
| on the consumer side. | |
| - New optional manifest fields MAY be added in minor versions and MUST be | |
| ignored by older consumers. | |
| - New trailer-format versions append fields after ``binary_sha256`` | |
| (consuming the reserved region) or extend the trailer with additional | |
| trailing blobs ahead of the magic. Older readers MUST reject unknown | |
| ``footer_ver`` rather than guessing. |