This is an automated email from the ASF dual-hosted git repository.
ephraimbuddy pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new f31684af859 Add the manifest format for manifest-backed versioned Dag
bundles (#72476)
f31684af859 is described below
commit f31684af8596377142ecc587cd9b2030eac86a8b
Author: Ephraim Anierobi <[email protected]>
AuthorDate: Wed Oct 14 12:43:51 2026 +0100
Add the manifest format for manifest-backed versioned Dag bundles (#72476)
* Add the manifest format for versioned Dag bundles
This is the first of several changes that add manifest-backed Dag bundles.
A manifest lists every file in a bundle with its checksum, and the bundle
version is a hash of that list.
Bundles get published and read on different machines, so both sides have to
agree on how the list is written and on what makes a version or a file path
valid. Keeping those rules in one place lets the storage backends that come
next share them instead of each keeping its own copy.
* Report unreadable bundle sources separately from changed ones
Reading a Dag bundle's source tree can fail for two different reasons. A
file
that vanished mid-walk means someone edited the Dag folder while we were
looking at it, and a publisher should simply try again on its next pass. A
permission problem or a stale network handle is a standing fault that an
operator has to see and fix.
Only the first was being caught, so the second escaped as a raw OSError
instead of a bundle error, and folding both into the "source changed" error
would leave a misconfigured Dag folder pinned to a stale version with
nothing
but a debug log to show for it.
* Let a manifest record the backend's object version for each file
An object store can keep several versions of the same key, so the key a
release
was built from is not necessarily the one a consumer fetches later.
Carrying the
backend's own identifier for each stored object, such as an S3 VersionId,
leaves
room to pin a fetch to the exact object instead of trusting whatever
currently
sits at the key.
Nothing populates it yet; it is optional so the local backend, which has no
such
identifier, is unaffected. It is deliberately excluded from the bundle
version:
republishing identical files to a versioned bucket mints new object
versions, and
that must still resolve to one bundle version.
* Fail a bundle source root the same way as the files inside it
Every other read of a bundle source now reports trouble as a manifest
error, but
a missing or unreadable root still escaped as a bare OSError, so a caller
that
guards a publish with the module's own error type would not catch it.
Unlike a
file vanishing mid-walk, a root we never saw is a configured path rather
than a
concurrent edit, so none of these say the source changed: a typo in the
configuration must not look like something worth quietly retrying.
Backend object versions are also checked before the hashing pass rather than
during it, so a bad identifier no longer costs a full read of the tree
first.
* Let a consumer check a manifest against its own contents
A digest only establishes that a manifest is the document it was taken
from, and a
consumer reading a manifest had no way to check the file entries against
anything.
Reading a malformed entry also failed as a stray KeyError rather than a
manifest error.
Re-deriving the version from the entries binds the two together, which is an
integrity guarantee only against a version the publisher did not supply,
such as an
operator's pin or one Airflow already recorded; checked against nothing but
itself, a
manifest is merely self-consistent. Making the caller state which case
applies keeps
the weaker one from being the default, and fixing the entry order keeps one
set of
files from being publishable under several versions.
---
.../src/airflow/dag_processing/bundles/manifest.py | 571 +++++++++++++
.../unit/dag_processing/bundles/test_manifest.py | 881 +++++++++++++++++++++
2 files changed, 1452 insertions(+)
diff --git a/airflow-core/src/airflow/dag_processing/bundles/manifest.py
b/airflow-core/src/airflow/dag_processing/bundles/manifest.py
new file mode 100644
index 00000000000..9a3e141f030
--- /dev/null
+++ b/airflow-core/src/airflow/dag_processing/bundles/manifest.py
@@ -0,0 +1,571 @@
+# 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.
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import stat
+from collections.abc import Iterator, Mapping, Sequence
+from dataclasses import dataclass
+from pathlib import Path, PurePosixPath
+from typing import Any, NoReturn
+
+from airflow.exceptions import AirflowException
+
+MANIFEST_FILE_NAME = ".airflow-bundle-manifest.json"
+IGNORED_DIR_NAMES = frozenset({".git", "__pycache__"})
+# ".git" as a file name covers git-worktree checkouts, where .git is a pointer
file.
+IGNORED_FILE_NAMES = frozenset({MANIFEST_FILE_NAME, ".git"})
+IGNORED_FILE_SUFFIXES = frozenset({".pyc"})
+MANIFEST_SCHEMA_VERSION = 1
+# "sha256-" (not "sha256:") keeps a version usable as a path segment: Airflow
builds
+# cache and tracking paths from the raw value.
+SHA256_VERSION_PREFIX = "sha256-"
+
+
+class BundleManifestError(AirflowException):
+ """
+ Base class for bundle manifest errors.
+
+ Subclasses ``AirflowException`` because every Dag bundle entry point
Airflow calls
+ is expected to fail with one, so a bundle that cannot be read is reported
per
+ bundle rather than escaping as an unrelated error type.
+ """
+
+
+class BundleManifestSourceChangedError(BundleManifestError):
+ """Raised when bundle source files change while a manifest is being
built."""
+
+
+@dataclass(frozen=True)
+class BundleSourceFile:
+ """A source file and the stat metadata used to detect source changes
during publishing."""
+
+ path: Path
+ relative_path: str
+ size: int
+ mtime_ns: int
+ ctime_ns: int
+ mode: int
+
+ def build_signature_record(self) -> dict[str, Any]:
+ return {
+ "path": self.relative_path,
+ "size": self.size,
+ "mtime_ns": self.mtime_ns,
+ "ctime_ns": self.ctime_ns,
+ "mode": self.mode,
+ }
+
+
+@dataclass(frozen=True)
+class BundleSourceSnapshot:
+ """A deterministic snapshot of the source tree metadata."""
+
+ root: Path
+ files: tuple[BundleSourceFile, ...]
+ signature: str
+
+
+def is_ignored_bundle_file_name(file_name: str) -> bool:
+ """Return whether manifest collection excludes one file, judged by its
name alone."""
+ return file_name in IGNORED_FILE_NAMES or PurePosixPath(file_name).suffix
in IGNORED_FILE_SUFFIXES
+
+
+@dataclass(frozen=True)
+class BundleVersionManifest:
+ """Full manifest plus the compact release-reference payload written to
latest.json."""
+
+ version: str
+ manifest: dict[str, Any]
+ ref_payload: dict[str, Any]
+ source_snapshot: BundleSourceSnapshot
+
+
+def _raise_source_access_error(error: OSError, *, subject: str, phase: str) ->
NoReturn:
+ """
+ Re-raise an OSError from the bundle source as the matching manifest error.
+
+ A missing entry means the source really did change under us, which a
publisher
+ treats as "nothing stable to publish yet, try the next pass". Any other
OSError --
+ a permission problem, a stale network handle -- is a standing fault, so it
must not
+ be reported as a benign concurrent edit and then quietly retried forever.
+ """
+ if isinstance(error, FileNotFoundError):
+ raise BundleManifestSourceChangedError(
+ f"Bundle source {subject} disappeared while {phase}"
+ ) from error
+ raise BundleManifestError(f"Bundle source {subject} became unreadable
while {phase}") from error
+
+
+def _raise_source_walk_error(error: OSError) -> NoReturn:
+ _raise_source_access_error(
+ error, subject=f"directory {error.filename}", phase="collecting
manifest metadata"
+ )
+
+
+def _iter_manifest_file_paths(root: Path) -> Iterator[tuple[Path,
os.stat_result]]:
+ for dirpath, dirnames, filenames in os.walk(
+ root,
+ followlinks=False,
+ onerror=_raise_source_walk_error,
+ ):
+ retained_dirnames: list[str] = []
+ for dirname in sorted(dirnames):
+ if dirname in IGNORED_DIR_NAMES:
+ continue
+ path = Path(dirpath) / dirname
+ try:
+ file_stat = path.lstat()
+ except OSError as e:
+ _raise_source_access_error(
+ e, subject=f"directory {path}", phase="collecting manifest
metadata"
+ )
+ if stat.S_ISLNK(file_stat.st_mode):
+ raise BundleManifestError(f"Bundle source contains symlinked
directory: {path}")
+ if not stat.S_ISDIR(file_stat.st_mode):
+ raise BundleManifestError(f"Bundle source contains
non-directory entry: {path}")
+ retained_dirnames.append(dirname)
+ dirnames[:] = retained_dirnames
+
+ for filename in sorted(filenames):
+ if is_ignored_bundle_file_name(filename):
+ continue
+ path = Path(dirpath) / filename
+ try:
+ file_stat = path.lstat()
+ except OSError as e:
+ _raise_source_access_error(e, subject=f"file {path}",
phase="collecting manifest metadata")
+ if stat.S_ISLNK(file_stat.st_mode):
+ raise BundleManifestError(f"Bundle source contains symlinked
file: {path}")
+ if not stat.S_ISREG(file_stat.st_mode):
+ raise BundleManifestError(f"Bundle source contains non-regular
file: {path}")
+ yield path, file_stat
+
+
+def _build_source_file(root: Path, path: Path, file_stat: os.stat_result) ->
BundleSourceFile:
+ return BundleSourceFile(
+ path=path,
+ relative_path=path.relative_to(root).as_posix(),
+ size=file_stat.st_size,
+ mtime_ns=file_stat.st_mtime_ns,
+ ctime_ns=file_stat.st_ctime_ns,
+ mode=stat.S_IMODE(file_stat.st_mode),
+ )
+
+
+def compute_file_sha256(path: Path) -> tuple[str, int]:
+ """Compute the sha256 hex digest and byte size of a file."""
+ digest = hashlib.sha256()
+ size = 0
+ with path.open("rb") as file:
+ for chunk in iter(lambda: file.read(1024 * 1024), b""):
+ digest.update(chunk)
+ size += len(chunk)
+ return digest.hexdigest(), size
+
+
+def compute_bundle_version(files: Sequence[dict[str, Any]]) -> str:
+ """
+ Compute the content-addressed bundle version from manifest file entries.
+
+ Only the identity fields (path, sha256, size, executable) participate, so
the
+ version stays stable if file entries ever gain extra metadata.
+ """
+ try:
+ payload = {
+ "schema_version": MANIFEST_SCHEMA_VERSION,
+ "files": [
+ {
+ "path": file_info["path"],
+ "sha256": file_info["sha256"],
+ "size": file_info["size"],
+ "executable": file_info["executable"],
+ }
+ for file_info in files
+ ],
+ }
+ except (KeyError, TypeError) as e:
+ raise BundleManifestError(
+ "Bundle manifest file entries must each contain path, sha256, size
and executable"
+ ) from e
+ return
f"{SHA256_VERSION_PREFIX}{hashlib.sha256(_serialize_manifest_payload(payload)).hexdigest()}"
+
+
+def is_sha256_hex(value: str) -> bool:
+ """Return whether a string is exactly 64 lowercase hex characters."""
+ return len(value) == 64 and all(character in "0123456789abcdef" for
character in value)
+
+
+def is_valid_bundle_version(version: str) -> bool:
+ """Return whether a string has the exact shape ``compute_bundle_version``
produces."""
+ digest = version.removeprefix(SHA256_VERSION_PREFIX)
+ return digest != version and is_sha256_hex(digest)
+
+
+def validate_bundle_version(version: str, *, source: str) -> str:
+ """
+ Return ``version`` if it is a well-formed bundle version, else raise.
+
+ Versions arrive from published documents and from operator-pinned
configuration, and
+ Airflow joins them into cache and tracking paths, so they are checked
before use.
+ """
+ if not is_valid_bundle_version(version):
+ raise BundleManifestError(f"Bundle {source} is not a valid sha256
version: {version!r}")
+ return version
+
+
+def validate_bundle_relative_path(relative_path: str) -> Path:
+ """
+ Return ``relative_path`` as a ``Path`` if it is safe to join onto a bundle
root, else raise.
+
+ Manifest paths are written by whoever published the bundle, so a consumer
must not
+ join one onto a local root before rejecting absolute paths, parent
traversal, the
+ non-normalized spellings (``a//b``, ``a/./b``, trailing slash) that would
resolve
+ somewhere other than where the manifest says, and control characters that
no
+ filesystem can represent. A backslash is an ordinary character in a POSIX
file
+ name, so it is accepted -- rejecting it would make a legitimate Dag file
+ publishable but never materializable.
+
+ This checks the path in isolation. Whether the manifest may legitimately
name the
+ path at all -- that it is not the manifest file itself, not a duplicate,
and not
+ excluded from collection -- is the caller's decision.
+ """
+ if any(character < " " for character in relative_path):
+ raise BundleManifestError(f"Bundle manifest contains unsafe relative
path: {relative_path!r}")
+ path = Path(relative_path)
+ if (
+ path.is_absolute()
+ or any(segment in {"", ".", ".."} for segment in
relative_path.split("/"))
+ or path.as_posix() != relative_path
+ ):
+ raise BundleManifestError(f"Bundle manifest contains unsafe relative
path: {relative_path!r}")
+ return path
+
+
+def _serialize_manifest_payload(payload: dict[str, Any]) -> bytes:
+ return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
+
+
+def serialize_bundle_version_manifest(manifest: dict[str, Any]) -> bytes:
+ """Serialize a bundle manifest deterministically for storage and
verification."""
+ return _serialize_manifest_payload(manifest)
+
+
+def _validate_bundle_root(root: Path) -> Path:
+ # Unlike a file vanishing mid-walk, a root we never saw is a configured
path rather
+ # than a concurrent edit, so none of these are reported as a changed
source: a typo
+ # in the configuration must not look like something worth quietly retrying.
+ root = Path(root)
+ try:
+ root_stat = root.stat()
+ except FileNotFoundError as e:
+ raise BundleManifestError(f"Bundle source path does not exist:
{root}") from e
+ except OSError as e:
+ raise BundleManifestError(f"Bundle source path is unreadable: {root}")
from e
+ if not stat.S_ISDIR(root_stat.st_mode):
+ raise BundleManifestError(f"Bundle source path is not a directory:
{root}")
+ return root
+
+
+def collect_bundle_source_snapshot(root: Path) -> BundleSourceSnapshot:
+ """Collect deterministic source-file metadata without reading file
contents."""
+ root = _validate_bundle_root(root)
+ files = [_build_source_file(root, path, file_stat) for path, file_stat in
_iter_manifest_file_paths(root)]
+ files.sort(key=lambda source_file: source_file.relative_path)
+ signature_payload = {
+ "schema_version": MANIFEST_SCHEMA_VERSION,
+ "files": [file.build_signature_record() for file in files],
+ }
+ signature =
f"sha256:{hashlib.sha256(_serialize_manifest_payload(signature_payload)).hexdigest()}"
+ return BundleSourceSnapshot(root=root, files=tuple(files),
signature=signature)
+
+
+def _ensure_source_file_unchanged(source_file: BundleSourceFile) -> None:
+ try:
+ current_stat = source_file.path.lstat()
+ except OSError as e:
+ _raise_source_access_error(
+ e, subject=f"file {source_file.relative_path}", phase="verifying
manifest metadata"
+ )
+
+ current_metadata = (
+ current_stat.st_size,
+ current_stat.st_mtime_ns,
+ current_stat.st_ctime_ns,
+ stat.S_IMODE(current_stat.st_mode),
+ )
+ expected_metadata = (
+ source_file.size,
+ source_file.mtime_ns,
+ source_file.ctime_ns,
+ source_file.mode,
+ )
+ if current_metadata != expected_metadata:
+ raise BundleManifestSourceChangedError(
+ f"Bundle source file changed while building manifest:
{source_file.relative_path}"
+ )
+
+
+def build_ref_payload(
+ *,
+ bundle_name: str,
+ version: str,
+ backend: dict[str, Any],
+ manifest_sha256: str,
+ file_count: int,
+ total_size: int,
+) -> dict[str, Any]:
+ """Build the compact release-reference payload that defines the
``latest.json`` format."""
+ return {
+ "schema_version": MANIFEST_SCHEMA_VERSION,
+ "bundle_name": bundle_name,
+ "version": version,
+ "backend": backend,
+ "manifest": {
+ "path": MANIFEST_FILE_NAME,
+ "sha256": manifest_sha256,
+ },
+ "file_count": file_count,
+ "total_size": total_size,
+ }
+
+
+def _build_ref_payload_from_manifest(manifest: dict[str, Any]) -> dict[str,
Any]:
+ manifest_sha256 =
hashlib.sha256(serialize_bundle_version_manifest(manifest)).hexdigest()
+ return build_ref_payload(
+ bundle_name=manifest["bundle_name"],
+ version=manifest["version"],
+ backend=manifest["backend"],
+ manifest_sha256=f"sha256:{manifest_sha256}",
+ file_count=manifest["file_count"],
+ total_size=manifest["total_size"],
+ )
+
+
+def build_bundle_version_manifest(
+ *,
+ bundle_name: str,
+ root: Path,
+ backend_type: str,
+ source_snapshot: BundleSourceSnapshot | None = None,
+ precomputed_file_sha256: Mapping[str, str] | None = None,
+ file_version_ids: Mapping[str, str] | None = None,
+) -> BundleVersionManifest:
+ """
+ Build a deterministic content manifest for a materialized Dag bundle root.
+
+ ``file_version_ids`` records the backend's own identifier for each stored
object, such
+ as an S3 ``VersionId``, so a consumer can fetch the exact object a release
was built
+ from rather than whatever currently sits at the key. It is backend
bookkeeping, not
+ content, so it stays out of ``compute_bundle_version`` -- republishing
identical files
+ to a versioned bucket mints new object versions and must still yield one
bundle version.
+ """
+ source_snapshot = source_snapshot or collect_bundle_source_snapshot(root)
+ root = _validate_bundle_root(root)
+ if source_snapshot.root != root:
+ raise ValueError("source_snapshot root does not match manifest root")
+ expected_paths = {source_file.relative_path for source_file in
source_snapshot.files}
+ if precomputed_file_sha256 is not None and set(precomputed_file_sha256) !=
expected_paths:
+ raise BundleManifestError("Precomputed file hashes do not match the
bundle source snapshot")
+ if file_version_ids is not None:
+ if set(file_version_ids) != expected_paths:
+ raise BundleManifestError("File version ids do not match the
bundle source snapshot")
+ for relative_path, version_id in sorted(file_version_ids.items()):
+ if not isinstance(version_id, str) or not version_id:
+ raise BundleManifestError(f"File version id is invalid for
{relative_path!r}")
+
+ files: list[dict[str, Any]] = []
+ total_size = 0
+ for source_file in source_snapshot.files:
+ if precomputed_file_sha256 is None:
+ try:
+ file_digest, _ = compute_file_sha256(source_file.path)
+ except OSError as e:
+ _raise_source_access_error(
+ e, subject=f"file {source_file.relative_path}",
phase="building manifest"
+ )
+ else:
+ file_digest = precomputed_file_sha256[source_file.relative_path]
+ if not is_sha256_hex(file_digest):
+ raise BundleManifestError(
+ f"Precomputed file hash is invalid for
{source_file.relative_path!r}"
+ )
+ _ensure_source_file_unchanged(source_file)
+ file_info = {
+ "path": source_file.relative_path,
+ "sha256": file_digest,
+ "size": source_file.size,
+ "executable": bool(source_file.mode & 0o111),
+ }
+ if file_version_ids is not None:
+ file_info["version_id"] =
file_version_ids[source_file.relative_path]
+ files.append(file_info)
+ total_size += source_file.size
+
+ version = compute_bundle_version(files)
+ manifest = {
+ "schema_version": MANIFEST_SCHEMA_VERSION,
+ "bundle_name": bundle_name,
+ "version": version,
+ "backend": {"type": backend_type},
+ "file_count": len(files),
+ "total_size": total_size,
+ "files": files,
+ }
+ return BundleVersionManifest(
+ version=version,
+ manifest=manifest,
+ ref_payload=_build_ref_payload_from_manifest(manifest),
+ source_snapshot=source_snapshot,
+ )
+
+
+def _validate_manifest_file_entry(file_info: Any) -> str:
+ if not isinstance(file_info, dict):
+ raise BundleManifestError("Bundle manifest file entries must be
objects")
+ relative_path = file_info.get("path")
+ if not isinstance(relative_path, str):
+ raise BundleManifestError("Bundle manifest file entry does not contain
a path")
+ validate_bundle_relative_path(relative_path)
+ if relative_path == MANIFEST_FILE_NAME:
+ raise BundleManifestError(f"Bundle manifest must not list
{MANIFEST_FILE_NAME!r} as a bundle file")
+
+ digest = file_info.get("sha256")
+ if not isinstance(digest, str) or not is_sha256_hex(digest):
+ raise BundleManifestError(f"Bundle manifest entry {relative_path!r}
does not contain a valid sha256")
+ size = file_info.get("size")
+ # bool is an int subclass and True == 1, so it would slip through the
checks below.
+ if not isinstance(size, int) or isinstance(size, bool) or size < 0:
+ raise BundleManifestError(f"Bundle manifest entry {relative_path!r}
does not contain a valid size")
+ if not isinstance(file_info.get("executable"), bool):
+ raise BundleManifestError(
+ f"Bundle manifest entry {relative_path!r} does not contain a valid
executable flag"
+ )
+ version_id = file_info.get("version_id")
+ if version_id is not None and (not isinstance(version_id, str) or not
version_id):
+ raise BundleManifestError(
+ f"Bundle manifest entry {relative_path!r} does not contain a valid
version id"
+ )
+ return relative_path
+
+
+def validate_bundle_version_manifest_structure(
+ manifest: Any,
+ *,
+ expected_version: str | None,
+ expected_bundle_name: str | None = None,
+ expected_backend_type: str | None = None,
+) -> None:
+ """
+ Check that a manifest is well formed and that its version commits to its
file entries.
+
+ Re-deriving the version from the entries binds the two together, but that
is only as
+ strong as the version it is compared with: a publisher who edits the
entries can
+ recompute the version as well, so a manifest checked against nothing but
itself is
+ merely self-consistent. Pass ``expected_version`` from somewhere the
publisher does not
+ control -- an operator's pin, or a version Airflow has already recorded --
to make this
+ an integrity check. It is required so that passing ``None`` is a
deliberate statement
+ that no such version is available.
+ """
+ if not isinstance(manifest, dict):
+ raise BundleManifestError("Bundle manifest must be an object")
+
+ schema_version = manifest.get("schema_version")
+ if schema_version != MANIFEST_SCHEMA_VERSION:
+ raise BundleManifestError(f"Bundle manifest has unsupported
schema_version {schema_version!r}")
+
+ bundle_name = manifest.get("bundle_name")
+ if not isinstance(bundle_name, str) or not bundle_name:
+ raise BundleManifestError("Bundle manifest does not contain a bundle
name")
+ if expected_bundle_name is not None and bundle_name !=
expected_bundle_name:
+ raise BundleManifestError(
+ f"Bundle manifest is for bundle {bundle_name!r}, expected
{expected_bundle_name!r}"
+ )
+
+ version = manifest.get("version")
+ if not isinstance(version, str):
+ raise BundleManifestError("Bundle manifest does not contain a version")
+ validate_bundle_version(version, source="manifest version")
+ if expected_version is not None and version != expected_version:
+ raise BundleManifestError(
+ f"Bundle manifest contains version {version!r}, expected
{expected_version!r}"
+ )
+
+ backend = manifest.get("backend")
+ if not isinstance(backend, dict) or not isinstance(backend.get("type"),
str):
+ raise BundleManifestError("Bundle manifest does not contain a backend
type")
+ if expected_backend_type is not None and backend["type"] !=
expected_backend_type:
+ # Worded to match the manifest bundle's existing "not for a local
backend" checks.
+ raise BundleManifestError(
+ f"Bundle manifest is not for a {expected_backend_type} backend:
got {backend['type']!r}"
+ )
+
+ files = manifest.get("files")
+ if not isinstance(files, list):
+ raise BundleManifestError("Bundle manifest files must be a list")
+
+ seen_paths: set[str] = set()
+ paths: list[str] = []
+ total_size = 0
+ for file_info in files:
+ relative_path = _validate_manifest_file_entry(file_info)
+ if relative_path in seen_paths:
+ raise BundleManifestError(f"Bundle manifest contains duplicate
path {relative_path!r}")
+ seen_paths.add(relative_path)
+ paths.append(relative_path)
+ total_size += file_info["size"]
+ # The version hashes entries in order, so without a fixed order the same
files could
+ # be published under several versions.
+ if paths != sorted(paths):
+ raise BundleManifestError("Bundle manifest file entries are not sorted
by path")
+
+ if manifest.get("file_count") != len(files):
+ raise BundleManifestError(
+ f"Bundle manifest file_count {manifest.get('file_count')!r} does
not match its "
+ f"{len(files)} file entries"
+ )
+ if manifest.get("total_size") != total_size:
+ raise BundleManifestError(
+ f"Bundle manifest total_size {manifest.get('total_size')!r} does
not match its "
+ f"entries, which total {total_size}"
+ )
+
+ computed_version = compute_bundle_version(files)
+ if version != computed_version:
+ raise BundleManifestError(
+ f"Bundle manifest version {version!r} does not match its contents,
"
+ f"which hash to {computed_version!r}"
+ )
+
+
+def verify_bundle_version_manifest(manifest: dict[str, Any], expected_sha256:
str) -> None:
+ """
+ Check that a manifest serializes to ``expected_sha256``.
+
+ This proves only that the manifest is the document the digest was taken
from, which
+ says nothing about the contents when the digest came from the same
publisher. Pair it
+ with ``validate_bundle_version_manifest_structure``, which carries its own
caveat.
+ """
+ actual_sha256 =
hashlib.sha256(serialize_bundle_version_manifest(manifest)).hexdigest()
+ expected_sha256 = expected_sha256.removeprefix("sha256:")
+ if actual_sha256 != expected_sha256:
+ raise BundleManifestError(
+ f"Bundle manifest digest mismatch: expected
sha256:{expected_sha256}, got sha256:{actual_sha256}"
+ )
diff --git a/airflow-core/tests/unit/dag_processing/bundles/test_manifest.py
b/airflow-core/tests/unit/dag_processing/bundles/test_manifest.py
new file mode 100644
index 00000000000..feee579e98e
--- /dev/null
+++ b/airflow-core/tests/unit/dag_processing/bundles/test_manifest.py
@@ -0,0 +1,881 @@
+# 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.
+
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import stat
+from dataclasses import replace
+from pathlib import Path
+
+import pytest
+
+from airflow.dag_processing.bundles import manifest as manifest_module
+from airflow.dag_processing.bundles.manifest import (
+ MANIFEST_FILE_NAME,
+ SHA256_VERSION_PREFIX,
+ BundleManifestError,
+ BundleManifestSourceChangedError,
+ build_bundle_version_manifest,
+ collect_bundle_source_snapshot,
+ compute_bundle_version,
+ is_ignored_bundle_file_name,
+ is_sha256_hex,
+ is_valid_bundle_version,
+ serialize_bundle_version_manifest,
+ validate_bundle_relative_path,
+ validate_bundle_version,
+ validate_bundle_version_manifest_structure,
+ verify_bundle_version_manifest,
+)
+
+
+def _build_manifest(**kwargs):
+ return build_bundle_version_manifest(**kwargs).manifest
+
+
+def _write_file(root, relative_path, content):
+ path = root / relative_path
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content)
+ return path
+
+
+def test_manifest_hash_is_stable_across_filesystem_creation_order(tmp_path):
+ first = tmp_path / "first"
+ second = tmp_path / "second"
+
+ _write_file(first, "dags/a.py", "print('a')")
+ _write_file(first, "dags/nested/b.py", "print('b')")
+ _write_file(second, "dags/nested/b.py", "print('b')")
+ _write_file(second, "dags/a.py", "print('a')")
+
+ first_manifest = _build_manifest(bundle_name="manifest-local", root=first,
backend_type="local")
+ second_manifest = _build_manifest(bundle_name="manifest-local",
root=second, backend_type="local")
+
+ assert first_manifest["version"] == second_manifest["version"]
+ assert first_manifest["files"] == second_manifest["files"]
+
+
+def test_manifest_hash_changes_when_file_content_changes(tmp_path):
+ source = tmp_path / "source"
+ _write_file(source, "dags/example.py", "print('first')")
+ first_manifest = _build_manifest(bundle_name="manifest-local",
root=source, backend_type="local")
+
+ _write_file(source, "dags/example.py", "print('second')")
+ second_manifest = _build_manifest(bundle_name="manifest-local",
root=source, backend_type="local")
+
+ assert first_manifest["version"] != second_manifest["version"]
+
+
+def test_bundle_version_only_uses_file_identity_fields():
+ file_info = {
+ "path": "example.py",
+ "sha256": "0" * 64,
+ "size": 1,
+ "executable": False,
+ "future_metadata": "ignored",
+ }
+
+ assert compute_bundle_version([file_info]) == compute_bundle_version(
+ [{key: value for key, value in file_info.items() if key !=
"future_metadata"}]
+ )
+
+
+def test_manifest_uses_complete_precomputed_file_hashes(tmp_path, monkeypatch):
+ source = tmp_path / "source"
+ content = "print('dag')"
+ _write_file(source, "dags/example.py", content)
+ source_snapshot = collect_bundle_source_snapshot(source)
+ expected_digest = hashlib.sha256(content.encode()).hexdigest()
+
+ def fail_hash(_):
+ raise AssertionError("precomputed hashes must skip a second source
read")
+
+ monkeypatch.setattr(manifest_module, "compute_file_sha256", fail_hash)
+ result = build_bundle_version_manifest(
+ bundle_name="manifest-s3",
+ root=source,
+ backend_type="local",
+ source_snapshot=source_snapshot,
+ precomputed_file_sha256={"dags/example.py": expected_digest},
+ )
+
+ assert result.manifest["files"][0]["sha256"] == expected_digest
+
+
[email protected](
+ ("precomputed_hashes", "expected_message"),
+ [
+ ({}, "Precomputed file hashes do not match"),
+ ({"other.py": "0" * 64}, "Precomputed file hashes do not match"),
+ ({"dags/example.py": "not-a-sha256"}, "Precomputed file hash is
invalid"),
+ ({"dags/example.py": "z" * 64}, "Precomputed file hash is invalid"),
+ ({"dags/example.py": "A" * 64}, "Precomputed file hash is invalid"),
+ ],
+)
+def test_manifest_rejects_invalid_precomputed_file_hashes(tmp_path,
precomputed_hashes, expected_message):
+ source = tmp_path / "source"
+ _write_file(source, "dags/example.py", "print('dag')")
+
+ with pytest.raises(BundleManifestError, match=expected_message):
+ build_bundle_version_manifest(
+ bundle_name="manifest-s3",
+ root=source,
+ backend_type="local",
+ precomputed_file_sha256=precomputed_hashes,
+ )
+
+
+def test_manifest_hash_changes_when_executable_bit_changes(tmp_path):
+ source = tmp_path / "source"
+ script = _write_file(source, "scripts/run.sh", "#!/bin/sh\n")
+ first_manifest = _build_manifest(bundle_name="manifest-local",
root=source, backend_type="local")
+
+ script.chmod(stat.S_IMODE(script.stat().st_mode) | stat.S_IXUSR)
+ second_manifest = _build_manifest(bundle_name="manifest-local",
root=source, backend_type="local")
+
+ assert first_manifest["version"] != second_manifest["version"]
+ assert first_manifest["files"][0]["executable"] is False
+ assert second_manifest["files"][0]["executable"] is True
+
+
+def test_manifest_rejects_source_symlink(tmp_path):
+ source = tmp_path / "source"
+ source.mkdir()
+ target = tmp_path / "outside.py"
+ target.write_text("print('outside')")
+ try:
+ (source / "linked.py").symlink_to(target)
+ except OSError as e:
+ pytest.skip(f"Symlinks are not supported in this test environment:
{e}")
+
+ with pytest.raises(BundleManifestError, match="symlinked file"):
+ _build_manifest(bundle_name="manifest-local", root=source,
backend_type="local")
+
+
+def test_manifest_rejects_source_symlinked_directory(tmp_path):
+ source = tmp_path / "source"
+ source.mkdir()
+ target = tmp_path / "outside"
+ target.mkdir()
+ try:
+ (source / "linked").symlink_to(target, target_is_directory=True)
+ except OSError as e:
+ pytest.skip(f"Symlinks are not supported in this test environment:
{e}")
+
+ with pytest.raises(BundleManifestError, match="symlinked directory"):
+ _build_manifest(bundle_name="manifest-local", root=source,
backend_type="local")
+
+
[email protected](
+ ("file_name", "expected"),
+ [
+ (".airflow-bundle-manifest.json", True),
+ (".git", True),
+ ("example.pyc", True),
+ ("example.py", False),
+ (".pyc", False),
+ ],
+)
+def test_ignored_bundle_file_names(file_name, expected):
+ assert is_ignored_bundle_file_name(file_name) is expected
+
+
+# A vanished entry is a benign concurrent edit a publisher retries past;
anything else
+# is a standing fault that must not be reported as one.
+SOURCE_ACCESS_ERROR_KINDS = [
+ pytest.param(
+ FileNotFoundError(2, "No such file or directory"),
+ BundleManifestSourceChangedError,
+ "disappeared while",
+ id="missing",
+ ),
+ pytest.param(
+ PermissionError(13, "Permission denied"),
+ BundleManifestError,
+ "became unreadable while",
+ id="unreadable",
+ ),
+ pytest.param(
+ OSError(116, "Stale file handle"),
+ BundleManifestError,
+ "became unreadable while",
+ id="stale-handle",
+ ),
+]
+
+
[email protected](("error", "expected_type", "expected_message"),
SOURCE_ACCESS_ERROR_KINDS)
+def test_manifest_maps_source_walk_errors_by_kind(
+ tmp_path, monkeypatch, error, expected_type, expected_message
+):
+ source = tmp_path / "source"
+ source.mkdir()
+ error.filename = str(source / "unreadable")
+
+ def fail_walk(*args, onerror, **kwargs):
+ onerror(error)
+
+ monkeypatch.setattr(manifest_module.os, "walk", fail_walk)
+
+ with pytest.raises(BundleManifestError, match=expected_message) as excinfo:
+ _build_manifest(bundle_name="manifest-local", root=source,
backend_type="local")
+
+ assert type(excinfo.value) is expected_type
+ assert "collecting manifest metadata" in str(excinfo.value)
+
+
[email protected]("entry_name", ["nested", "example.py"])
[email protected](("error", "expected_type", "expected_message"),
SOURCE_ACCESS_ERROR_KINDS)
+def test_manifest_maps_source_entry_stat_errors_by_kind(
+ tmp_path, monkeypatch, entry_name, error, expected_type, expected_message
+):
+ source = tmp_path / "source"
+ _write_file(source, "nested/example.py", "print('dag')")
+ real_lstat = Path.lstat
+
+ def failing_lstat(self, *args, **kwargs):
+ if self.name == entry_name:
+ raise error
+ return real_lstat(self, *args, **kwargs)
+
+ monkeypatch.setattr(Path, "lstat", failing_lstat)
+
+ with pytest.raises(BundleManifestError, match=expected_message) as excinfo:
+ _build_manifest(bundle_name="manifest-local", root=source,
backend_type="local")
+
+ assert type(excinfo.value) is expected_type
+ assert "collecting manifest metadata" in str(excinfo.value)
+
+
+def test_manifest_reports_an_unreadable_source_file_during_hashing(tmp_path,
monkeypatch):
+ source = tmp_path / "source"
+ _write_file(source, "dags/example.py", "print('dag')")
+
+ def unreadable(_):
+ raise PermissionError(13, "Permission denied")
+
+ monkeypatch.setattr(manifest_module, "compute_file_sha256", unreadable)
+
+ with pytest.raises(BundleManifestError, match="became unreadable while
building manifest") as excinfo:
+ _build_manifest(bundle_name="manifest-local", root=source,
backend_type="local")
+
+ assert type(excinfo.value) is BundleManifestError
+
+
+def
test_manifest_reports_an_unreadable_source_file_during_verification(tmp_path,
monkeypatch):
+ source = tmp_path / "source"
+ _write_file(source, "example.py", "content")
+ source_snapshot = collect_bundle_source_snapshot(source)
+ digest = hashlib.sha256(b"content").hexdigest()
+
+ def unreadable(self, *args, **kwargs):
+ raise PermissionError(13, "Permission denied")
+
+ # Precomputed hashes skip the read, so verification is the first access to
the file.
+ monkeypatch.setattr(Path, "lstat", unreadable)
+
+ with pytest.raises(
+ BundleManifestError, match="became unreadable while verifying manifest
metadata"
+ ) as excinfo:
+ build_bundle_version_manifest(
+ bundle_name="manifest-local",
+ root=source,
+ backend_type="local",
+ source_snapshot=source_snapshot,
+ precomputed_file_sha256={"example.py": digest},
+ )
+
+ assert type(excinfo.value) is BundleManifestError
+
+
+def test_manifest_ignores_cache_and_vcs_files(tmp_path):
+ source = tmp_path / "source"
+ _write_file(source, "dags/example.py", "print('dag')")
+ manifest_before = _build_manifest(bundle_name="manifest-local",
root=source, backend_type="local")
+
+ _write_file(source, ".git/config", "[core]")
+ _write_file(source, MANIFEST_FILE_NAME, "{}")
+ _write_file(source, "dags/__pycache__/example.cpython-312.pyc", "compiled")
+ manifest_after = _build_manifest(bundle_name="manifest-local",
root=source, backend_type="local")
+
+ assert manifest_after["version"] == manifest_before["version"]
+ assert [file_info["path"] for file_info in manifest_after["files"]] ==
["dags/example.py"]
+
+
+def test_manifest_ignores_symlinked_ignored_directory(tmp_path):
+ source = tmp_path / "source"
+ _write_file(source, "dags/example.py", "print('dag')")
+ manifest_before = _build_manifest(bundle_name="manifest-local",
root=source, backend_type="local")
+
+ real_git_dir = tmp_path / "real-git"
+ real_git_dir.mkdir()
+ try:
+ (source / ".git").symlink_to(real_git_dir, target_is_directory=True)
+ except OSError as e:
+ pytest.skip(f"Symlinks are not supported in this test environment:
{e}")
+ manifest_after = _build_manifest(bundle_name="manifest-local",
root=source, backend_type="local")
+
+ assert manifest_after["version"] == manifest_before["version"]
+
+
+def test_manifest_ignores_git_worktree_pointer_file(tmp_path):
+ source = tmp_path / "source"
+ _write_file(source, "dags/example.py", "print('dag')")
+ manifest_before = _build_manifest(bundle_name="manifest-local",
root=source, backend_type="local")
+
+ _write_file(source, ".git", "gitdir: /repos/main/.git/worktrees/dags")
+ manifest_after = _build_manifest(bundle_name="manifest-local",
root=source, backend_type="local")
+
+ assert manifest_after["version"] == manifest_before["version"]
+ assert [file_info["path"] for file_info in manifest_after["files"]] ==
["dags/example.py"]
+
+
+def test_manifest_paths_are_relative_and_sorted(tmp_path):
+ source = tmp_path / "source"
+ _write_file(source, "z.py", "print('z')")
+ _write_file(source, "a/nested.py", "print('nested')")
+ _write_file(source, "a.py", "print('a')")
+
+ manifest = _build_manifest(bundle_name="manifest-local", root=source,
backend_type="local")
+ paths = [file_info["path"] for file_info in manifest["files"]]
+
+ assert paths == ["a.py", "a/nested.py", "z.py"]
+ assert str(source) not in json.dumps(manifest)
+
+
+def test_ref_payload_is_compact_and_points_to_manifest(tmp_path):
+ source = tmp_path / "source"
+ _write_file(source, "dags/example.py", "print('dag')")
+
+ result = build_bundle_version_manifest(bundle_name="manifest-local",
root=source, backend_type="local")
+
+ assert result.ref_payload == {
+ "schema_version": 1,
+ "bundle_name": "manifest-local",
+ "version": result.version,
+ "backend": {"type": "local"},
+ "manifest": {
+ "path": MANIFEST_FILE_NAME,
+ "sha256": result.ref_payload["manifest"]["sha256"],
+ },
+ "file_count": 1,
+ "total_size": len("print('dag')"),
+ }
+ assert result.ref_payload["manifest"]["sha256"].startswith("sha256:")
+ assert "files" not in result.ref_payload
+ assert str(source) not in json.dumps(result.ref_payload)
+
+
[email protected](
+ ("source_name", "source_factory", "expected_message"),
+ [
+ ("missing", lambda path: None, "does not exist"),
+ ("file", lambda path: path.write_text("not a directory"), "is not a
directory"),
+ ],
+)
+def test_manifest_rejects_invalid_source_roots(tmp_path, source_name,
source_factory, expected_message):
+ source = tmp_path / source_name
+ source_factory(source)
+
+ with pytest.raises(BundleManifestError, match=expected_message) as excinfo:
+ collect_bundle_source_snapshot(source)
+
+ # A configured root that is wrong is a standing fault, never a concurrent
edit.
+ assert type(excinfo.value) is BundleManifestError
+
+
+def test_manifest_rejects_an_unreadable_source_root(tmp_path, monkeypatch):
+ source = tmp_path / "source"
+ _write_file(source, "dags/example.py", "print('dag')")
+
+ def unreadable(self, *args, **kwargs):
+ raise PermissionError(13, "Permission denied")
+
+ monkeypatch.setattr(Path, "stat", unreadable)
+
+ with pytest.raises(BundleManifestError, match="is unreadable") as excinfo:
+ collect_bundle_source_snapshot(source)
+
+ assert type(excinfo.value) is BundleManifestError
+
+
+def test_manifest_rejects_snapshot_from_another_root(tmp_path):
+ first = tmp_path / "first"
+ second = tmp_path / "second"
+ _write_file(first, "example.py", "first")
+ _write_file(second, "example.py", "second")
+
+ with pytest.raises(ValueError, match="source_snapshot root"):
+ build_bundle_version_manifest(
+ bundle_name="manifest-local",
+ root=second,
+ backend_type="local",
+ source_snapshot=collect_bundle_source_snapshot(first),
+ )
+
+
+def test_manifest_rejects_file_removed_after_source_snapshot(tmp_path):
+ source = tmp_path / "source"
+ source_file = _write_file(source, "example.py", "content")
+ source_snapshot = collect_bundle_source_snapshot(source)
+ source_file.unlink()
+
+ with pytest.raises(BundleManifestSourceChangedError, match="disappeared
while building manifest"):
+ build_bundle_version_manifest(
+ bundle_name="manifest-local",
+ root=source,
+ backend_type="local",
+ source_snapshot=source_snapshot,
+ )
+
+
+def
test_manifest_rejects_file_metadata_changed_after_source_snapshot(tmp_path):
+ source = tmp_path / "source"
+ _write_file(source, "example.py", "content")
+ source_snapshot = collect_bundle_source_snapshot(source)
+ changed_file = replace(source_snapshot.files[0],
size=source_snapshot.files[0].size + 1)
+
+ with pytest.raises(BundleManifestSourceChangedError, match="changed while
building manifest"):
+ build_bundle_version_manifest(
+ bundle_name="manifest-local",
+ root=source,
+ backend_type="local",
+ source_snapshot=replace(source_snapshot, files=(changed_file,)),
+ )
+
+
+def test_manifest_verification_accepts_prefixed_and_bare_digests(tmp_path):
+ source = tmp_path / "source"
+ _write_file(source, "example.py", "content")
+ manifest = _build_manifest(bundle_name="manifest-local", root=source,
backend_type="local")
+ digest =
hashlib.sha256(serialize_bundle_version_manifest(manifest)).hexdigest()
+
+ verify_bundle_version_manifest(manifest, digest)
+ verify_bundle_version_manifest(manifest, f"sha256:{digest}")
+
+
+def test_manifest_verification_rejects_digest_mismatch(tmp_path):
+ source = tmp_path / "source"
+ _write_file(source, "example.py", "content")
+ manifest = _build_manifest(bundle_name="manifest-local", root=source,
backend_type="local")
+
+ with pytest.raises(BundleManifestError, match="manifest digest mismatch"):
+ verify_bundle_version_manifest(manifest, "0" * 64)
+
+
+def test_computed_bundle_version_passes_its_own_validator(tmp_path):
+ source = tmp_path / "source"
+ _write_file(source, "dags/example.py", "print('dag')")
+
+ version = _build_manifest(bundle_name="manifest-local", root=source,
backend_type="local")["version"]
+
+ assert version.startswith(SHA256_VERSION_PREFIX)
+ assert is_valid_bundle_version(version)
+ assert validate_bundle_version(version, source="test") == version
+
+
[email protected](
+ "version",
+ [
+ "",
+ "../outside",
+ "sha256-bad",
+ f"sha256-{'A' * 64}",
+ f"sha256-{'a' * 63}",
+ f"sha256-{'a' * 65}",
+ f"sha256:{'a' * 64}",
+ "a" * 64,
+ f"sha256-{'a' * 63}/",
+ f"sha256-sha256-{'a' * 50}",
+ ],
+)
+def test_bundle_version_validation_rejects_malformed_versions(version):
+ assert is_valid_bundle_version(version) is False
+ with pytest.raises(BundleManifestError, match="valid sha256 version"):
+ validate_bundle_version(version, source="pinned bundle version")
+
+
[email protected]("relative_path", ["a.py", "dags/example.py",
"dags/nested/example.py"])
+def
test_bundle_relative_path_validation_keeps_accepted_paths_under_the_root(tmp_path,
relative_path):
+ resolved = (tmp_path /
validate_bundle_relative_path(relative_path)).resolve()
+
+ assert resolved.is_relative_to(tmp_path.resolve())
+ assert resolved != tmp_path.resolve()
+
+
[email protected](
+ "relative_path",
+ [
+ "",
+ "..",
+ "../evil.py",
+ "dags/../../evil.py",
+ "/tmp/evil.py",
+ "dags//example.py",
+ "dags/./example.py",
+ "dags/",
+ ".",
+ "./example.py",
+ "/",
+ "dags/ex\x00ample.py",
+ "dags/ex\nample.py",
+ "dags/ex\tample.py",
+ ],
+)
+def test_bundle_relative_path_validation_rejects_unsafe_paths(relative_path):
+ with pytest.raises(BundleManifestError, match="unsafe relative path"):
+ validate_bundle_relative_path(relative_path)
+
+
+def test_source_verification_does_not_follow_symlinks(tmp_path, monkeypatch):
+ source = tmp_path / "source"
+ source_file = _write_file(source, "example.py", "content")
+ source_snapshot = collect_bundle_source_snapshot(source)
+
+ followed: list[Path] = []
+ real_stat = Path.stat
+
+ # Path.lstat() delegates to Path.stat(follow_symlinks=False), so only the
+ # following calls distinguish "verified the file itself" from "verified
its target".
+ def recording_stat(self, *, follow_symlinks=True):
+ if follow_symlinks:
+ followed.append(Path(self))
+ return real_stat(self, follow_symlinks=follow_symlinks)
+
+ monkeypatch.setattr(Path, "stat", recording_stat)
+ build_bundle_version_manifest(
+ bundle_name="manifest-local",
+ root=source,
+ backend_type="local",
+ source_snapshot=source_snapshot,
+ )
+
+ assert source_file not in followed
+
+
+def test_bundle_version_validation_names_the_source_in_the_message():
+ with pytest.raises(BundleManifestError, match="pinned bundle version"):
+ validate_bundle_version("sha256-bad", source="pinned bundle version")
+
+
[email protected](
+ ("value", "expected"),
+ [("a" * 64, True), ("0123456789abcdef" * 4, True), ("A" * 64, False), ("z"
* 64, False)],
+)
+def test_sha256_hex_recognition(value, expected):
+ assert is_sha256_hex(value) is expected
+
+
+def test_manifest_ignores_non_bytecode_files_under_pycache(tmp_path):
+ source = tmp_path / "source"
+ _write_file(source, "dags/example.py", "print('dag')")
+ manifest_before = _build_manifest(bundle_name="manifest-local",
root=source, backend_type="local")
+
+ _write_file(source, "dags/__pycache__/notes.txt", "not bytecode")
+ manifest_after = _build_manifest(bundle_name="manifest-local",
root=source, backend_type="local")
+
+ assert manifest_after["version"] == manifest_before["version"]
+ assert [file_info["path"] for file_info in manifest_after["files"]] ==
["dags/example.py"]
+
+
+def
test_manifest_serialization_is_independent_of_key_insertion_order(tmp_path):
+ source = tmp_path / "source"
+ _write_file(source, "dags/example.py", "print('dag')")
+ manifest = _build_manifest(bundle_name="manifest-local", root=source,
backend_type="local")
+ reordered = dict(reversed(list(manifest.items())))
+
+ assert list(reordered) != list(manifest)
+ assert serialize_bundle_version_manifest(reordered) ==
serialize_bundle_version_manifest(manifest)
+
+
+def test_source_snapshot_signature_covers_every_metadata_field(tmp_path):
+ source = tmp_path / "source"
+ _write_file(source, "example.py", "content")
+ source_snapshot = collect_bundle_source_snapshot(source)
+
+ assert source_snapshot.signature.startswith("sha256:")
+ for field, value in [("size", 1), ("mtime_ns", 1), ("ctime_ns", 1),
("mode", 0o600)]:
+ changed = replace(source_snapshot.files[0], **{field: value})
+ assert changed.build_signature_record() !=
source_snapshot.files[0].build_signature_record()
+
+
[email protected](
+ ("field", "value"),
+ [("size", 999), ("mtime_ns", 1), ("ctime_ns", 1), ("mode", 0o600)],
+)
+def test_manifest_rejects_any_source_metadata_field_changing(tmp_path, field,
value):
+ source = tmp_path / "source"
+ _write_file(source, "example.py", "content")
+ source_snapshot = collect_bundle_source_snapshot(source)
+ changed_file = replace(source_snapshot.files[0], **{field: value})
+
+ with pytest.raises(BundleManifestSourceChangedError, match="changed while
building manifest"):
+ build_bundle_version_manifest(
+ bundle_name="manifest-local",
+ root=source,
+ backend_type="local",
+ source_snapshot=replace(source_snapshot, files=(changed_file,)),
+ )
+
+
+def
test_manifest_rejects_file_removed_between_hashing_and_verification(tmp_path):
+ source = tmp_path / "source"
+ source_file = _write_file(source, "example.py", "content")
+ source_snapshot = collect_bundle_source_snapshot(source)
+ digest = hashlib.sha256(b"content").hexdigest()
+ source_file.unlink()
+
+ # Precomputed hashes skip the read, so verification is the first access to
the file.
+ with pytest.raises(BundleManifestSourceChangedError, match="disappeared
while verifying"):
+ build_bundle_version_manifest(
+ bundle_name="manifest-local",
+ root=source,
+ backend_type="local",
+ source_snapshot=source_snapshot,
+ precomputed_file_sha256={"example.py": digest},
+ )
+
+
+def
test_backslash_in_a_posix_file_name_survives_publish_and_validation(tmp_path):
+ source = tmp_path / "source"
+ _write_file(source, "dags/weird\\name.py", "print('dag')")
+
+ manifest = _build_manifest(bundle_name="manifest-local", root=source,
backend_type="local")
+ recorded_path = manifest["files"][0]["path"]
+
+ assert recorded_path == "dags/weird\\name.py"
+ assert validate_bundle_relative_path(recorded_path) == Path(recorded_path)
+
+
+def test_manifest_records_backend_file_version_ids(tmp_path):
+ source = tmp_path / "source"
+ _write_file(source, "dags/a.py", "print('a')")
+ _write_file(source, "dags/b.py", "print('b')")
+
+ manifest = _build_manifest(
+ bundle_name="manifest-s3",
+ root=source,
+ backend_type="s3",
+ file_version_ids={"dags/a.py": "3HL4kqtJlcpXroDTDmJ+rm", "dags/b.py":
"nspShmM1rXMPGZ4qkCwTPw"},
+ )
+
+ assert [file_info["version_id"] for file_info in manifest["files"]] == [
+ "3HL4kqtJlcpXroDTDmJ+rm",
+ "nspShmM1rXMPGZ4qkCwTPw",
+ ]
+
+
+def test_manifest_omits_version_id_when_the_backend_supplies_none(tmp_path):
+ source = tmp_path / "source"
+ _write_file(source, "dags/example.py", "print('dag')")
+
+ manifest = _build_manifest(bundle_name="manifest-local", root=source,
backend_type="local")
+
+ assert "version_id" not in manifest["files"][0]
+
+
+def test_bundle_version_ignores_backend_file_version_ids(tmp_path):
+ source = tmp_path / "source"
+ _write_file(source, "dags/example.py", "print('dag')")
+ kwargs = {"bundle_name": "manifest-s3", "root": source, "backend_type":
"s3"}
+
+ without = _build_manifest(**kwargs)
+ first = _build_manifest(**kwargs, file_version_ids={"dags/example.py":
"objver-one"})
+ second = _build_manifest(**kwargs, file_version_ids={"dags/example.py":
"objver-two"})
+
+ assert without["version"] == first["version"] == second["version"]
+ assert first["files"][0]["version_id"] != second["files"][0]["version_id"]
+
+
[email protected](
+ ("file_version_ids", "expected_message"),
+ [
+ ({}, "File version ids do not match"),
+ ({"other.py": "objver"}, "File version ids do not match"),
+ ({"dags/example.py": ""}, "File version id is invalid"),
+ ({"dags/example.py": 1}, "File version id is invalid"),
+ ],
+)
+def test_manifest_rejects_invalid_file_version_ids(tmp_path, file_version_ids,
expected_message):
+ source = tmp_path / "source"
+ _write_file(source, "dags/example.py", "print('dag')")
+
+ with pytest.raises(BundleManifestError, match=expected_message):
+ _build_manifest(
+ bundle_name="manifest-s3",
+ root=source,
+ backend_type="s3",
+ file_version_ids=file_version_ids,
+ )
+
+
+def
test_manifest_rejects_bad_file_version_ids_before_reading_the_tree(tmp_path,
monkeypatch):
+ source = tmp_path / "source"
+ for index in range(3):
+ _write_file(source, f"dags/f{index}.py", "print('dag')")
+ hashed: list[str] = []
+
+ def recording_sha256(path):
+ hashed.append(path.name)
+ raise AssertionError("invalid file version ids must be rejected before
hashing")
+
+ monkeypatch.setattr(manifest_module, "compute_file_sha256",
recording_sha256)
+
+ with pytest.raises(BundleManifestError, match="File version id is invalid
for 'dags/f2.py'"):
+ _build_manifest(
+ bundle_name="manifest-s3",
+ root=source,
+ backend_type="s3",
+ file_version_ids={"dags/f0.py": "ok", "dags/f1.py": "ok",
"dags/f2.py": ""},
+ )
+
+ assert hashed == []
+
+
+def _published_manifest(tmp_path, **kwargs):
+ source = tmp_path / "source"
+ _write_file(source, "dags/example.py", "print('dag')")
+ _write_file(source, "dags/nested/other.py", "print('other')")
+ return _build_manifest(bundle_name="manifest-local", root=source,
backend_type="local", **kwargs)
+
+
+def test_structure_validation_accepts_a_freshly_built_manifest(tmp_path):
+ manifest = _published_manifest(tmp_path)
+
+ validate_bundle_version_manifest_structure(
+ manifest,
+ expected_bundle_name="manifest-local",
+ expected_backend_type="local",
+ expected_version=manifest["version"],
+ )
+
+
+def test_structure_validation_accepts_entries_carrying_a_version_id(tmp_path):
+ source = tmp_path / "source"
+ _write_file(source, "dags/example.py", "print('dag')")
+ manifest = _build_manifest(
+ bundle_name="manifest-s3",
+ root=source,
+ backend_type="s3",
+ file_version_ids={"dags/example.py": "objver-1"},
+ )
+
+ validate_bundle_version_manifest_structure(manifest,
expected_version=None, expected_backend_type="s3")
+
+
+def
test_structure_validation_is_an_integrity_check_only_against_a_trusted_version(tmp_path):
+ manifest = _published_manifest(tmp_path)
+ trusted_version = manifest["version"]
+
+ edited = copy.deepcopy(manifest)
+ edited["files"][0]["sha256"] = "f" * 64
+ verify_bundle_version_manifest(
+ edited,
hashlib.sha256(serialize_bundle_version_manifest(edited)).hexdigest()
+ )
+ with pytest.raises(BundleManifestError, match="does not match its
contents"):
+ validate_bundle_version_manifest_structure(edited,
expected_version=None)
+
+ forged = copy.deepcopy(edited)
+ forged["version"] = compute_bundle_version(forged["files"])
+ validate_bundle_version_manifest_structure(forged, expected_version=None)
+ with pytest.raises(BundleManifestError, match="expected 'sha256-"):
+ validate_bundle_version_manifest_structure(forged,
expected_version=trusted_version)
+
+
+def test_structure_validation_rejects_entries_out_of_path_order(tmp_path):
+ manifest = _published_manifest(tmp_path)
+ manifest["files"].reverse()
+ manifest["version"] = compute_bundle_version(manifest["files"])
+
+ with pytest.raises(BundleManifestError, match="not sorted by path"):
+ validate_bundle_version_manifest_structure(manifest,
expected_version=None)
+
+
+def test_structure_validation_requires_an_explicit_expected_version(tmp_path):
+ manifest = _published_manifest(tmp_path)
+
+ with pytest.raises(TypeError, match="expected_version"):
+ validate_bundle_version_manifest_structure(manifest)
+
+
[email protected](
+ ("mutate", "expected_message"),
+ [
+ (lambda m: m.update({"schema_version": 2}), "unsupported
schema_version"),
+ (lambda m: m.update({"bundle_name": None}), "does not contain a bundle
name"),
+ (lambda m: m.update({"version": None}), "does not contain a version"),
+ (lambda m: m.update({"version": "sha256-nope"}), "valid sha256
version"),
+ (lambda m: m.update({"backend": "local"}), "does not contain a backend
type"),
+ (lambda m: m.update({"files": {}}), "files must be a list"),
+ (lambda m: m.update({"file_count": 99}), "file_count"),
+ (lambda m: m.update({"total_size": 99}), "total_size"),
+ (lambda m: m["files"].append(dict(m["files"][0])), "duplicate path"),
+ (lambda m: m["files"].__setitem__(0, "not-an-object"), "entries must
be objects"),
+ (lambda m: m["files"][0].update({"path": 1}), "does not contain a
path"),
+ (lambda m: m["files"][0].update({"path": "../evil.py"}), "unsafe
relative path"),
+ (lambda m: m["files"][0].update({"path": MANIFEST_FILE_NAME}), "must
not list"),
+ (lambda m: m["files"][0].update({"sha256": "nope"}), "valid sha256"),
+ (lambda m: m["files"][0].update({"size": -1}), "valid size"),
+ (lambda m: m["files"][0].update({"size": True}), "valid size"),
+ (lambda m: m["files"][0].update({"executable": "yes"}), "valid
executable flag"),
+ (lambda m: m["files"][0].update({"version_id": ""}), "valid version
id"),
+ ],
+)
+def test_structure_validation_rejects_malformed_manifests(tmp_path, mutate,
expected_message):
+ manifest = _published_manifest(tmp_path)
+ mutate(manifest)
+
+ with pytest.raises(BundleManifestError, match=expected_message):
+ validate_bundle_version_manifest_structure(manifest,
expected_version=None)
+
+
[email protected](
+ ("kwargs", "expected_message"),
+ [
+ ({"expected_bundle_name": "other"}, "expected 'other'"),
+ ({"expected_backend_type": "s3"}, "not for a s3 backend: got 'local'"),
+ ({"expected_version": "sha256-" + "a" * 64}, "expected 'sha256-"),
+ ],
+)
+def test_structure_validation_rejects_a_manifest_for_something_else(tmp_path,
kwargs, expected_message):
+ manifest = _published_manifest(tmp_path)
+
+ with pytest.raises(BundleManifestError, match=expected_message):
+ validate_bundle_version_manifest_structure(manifest,
**{"expected_version": None, **kwargs})
+
+
+def test_structure_validation_rejects_a_manifest_that_is_not_an_object():
+ with pytest.raises(BundleManifestError, match="must be an object"):
+ validate_bundle_version_manifest_structure(["not", "a", "manifest"],
expected_version=None)
+
+
[email protected](
+ "files",
+ [
+ [{"path": "a.py", "sha256": "0" * 64, "size": 1}],
+ [{"sha256": "0" * 64, "size": 1, "executable": False}],
+ ["not-an-object"],
+ [None],
+ ],
+)
+def test_bundle_version_rejects_incomplete_file_entries(files):
+ with pytest.raises(BundleManifestError, match="must each contain path,
sha256, size and executable"):
+ compute_bundle_version(files)