Skip to content

Commit 98bfa5b

Browse files
committed
Harden claimed-file discovery errors and archive importers
- Resolve a relative discovery-error reference against the bundle, map an archive member reference to its archive, and drop paths outside the bundle. A bundle-relative reference was resolved against the working directory, which dropped the file or crashed the Dag processor. - Key a single file's listing errors by the file, not ".". - Reject a ZipImporter as a claiming importer: it imports archive members, whose Dags could not be persisted, so each claimed file now reports the configuration error. - Warn once per registry when .py, .pyc or .zip is mapped to another importer.
1 parent 89e8f78 commit 98bfa5b

5 files changed

Lines changed: 228 additions & 24 deletions

File tree

‎airflow-core/src/airflow/dag_processing/importer_routing.py‎

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,24 @@
2525

2626
from __future__ import annotations
2727

28+
import dataclasses
2829
import logging
2930
import os
31+
import weakref
3032
from dataclasses import dataclass
3133
from pathlib import Path
3234
from typing import TYPE_CHECKING, Any, cast
3335

36+
from airflow.exceptions import AirflowConfigException
3437
from airflow.sdk.importers import (
3538
AbstractDagImporter,
3639
DagImporterRegistry,
3740
DagImportError,
3841
DagImportResult,
3942
DagSourceCode,
4043
FilesystemDagDefinition,
44+
PythonDagImporter,
45+
ZipImporter,
4146
ZipMemberDagDefinition,
4247
find_file_dag_definitions,
4348
get_file_suffix,
@@ -54,15 +59,16 @@
5459
LEGACY_EXTENSIONS = frozenset({".py", ".pyc", ".zip"})
5560

5661
_reported_registry_failures: set[str | None] = set()
62+
_checked_registries: weakref.WeakSet[DagImporterRegistry] = weakref.WeakSet()
5763

5864

5965
@dataclass(frozen=True)
6066
class BundleRef:
6167
"""
6268
The part of a Dag bundle that importers read: its name and its root path.
6369
64-
Core passes it in place of a ``BaseDagBundle`` when it imports a single file, so an importer
65-
must not rely on any other bundle attribute.
70+
Core passes it in place of a ``BaseDagBundle`` when a Dag bag lists a folder or imports a file,
71+
so an importer must not rely on any other bundle attribute.
6672
"""
6773

6874
name: str | None
@@ -88,9 +94,25 @@ def get_task_sdk_registry(bundle_name: str | None) -> DagImporterRegistry | None
8894
log.exception("Cannot build the Task SDK Dag importer registry for bundle %s", bundle_name)
8995
return None
9096
_reported_registry_failures.discard(bundle_name)
97+
_warn_about_ignored_legacy_importers(registry)
9198
return registry
9299

93100

101+
def _warn_about_ignored_legacy_importers(registry: DagImporterRegistry) -> None:
102+
if registry in _checked_registries:
103+
return
104+
_checked_registries.add(registry)
105+
for ext in sorted(LEGACY_EXTENSIONS.intersection(registry.supported_extensions())):
106+
try:
107+
importer = registry.get_importer(f"_{ext}")
108+
except Exception:
109+
importer = None
110+
if type(importer) not in (PythonDagImporter, ZipImporter):
111+
log.warning(
112+
"Ignoring the Dag importer configured for %s files: they always use the legacy importer", ext
113+
)
114+
115+
94116
def _get_claimed_extensions(registry: DagImporterRegistry) -> list[str]:
95117
return [ext for ext in registry.supported_extensions() if ext not in LEGACY_EXTENSIONS]
96118

@@ -111,11 +133,18 @@ def get_claiming_importer(
111133
"""
112134
Return the Task SDK importer that claims ``path``, or ``None`` when the file is not claimed.
113135
114-
:raises AirflowConfigException: if the importer configured for the extension cannot be loaded.
136+
:raises AirflowConfigException: if the importer configured for the extension cannot be loaded,
137+
or imports archive members, which is not supported for a claimed file.
115138
"""
116139
if not is_claimed(registry, path):
117140
return None
118-
return registry.get_importer(Path(path))
141+
importer = registry.get_importer(Path(path))
142+
if isinstance(importer, ZipImporter):
143+
raise AirflowConfigException(
144+
f"{type(importer).__name__} cannot claim {get_file_suffix(Path(path))} files: an importer "
145+
"that claims files must import each file as one Dag definition, not as archive members."
146+
)
147+
return importer
119148

120149

121150
def _get_claiming_importer_or_none(
@@ -149,24 +178,42 @@ def has_claiming_importers(registry: DagImporterRegistry) -> bool:
149178
return bool(_get_claimed_extensions(registry))
150179

151180

181+
def _find_bundle_file(bundle_path: Path, reference: Path) -> Path | None:
182+
"""
183+
Return the file in the bundle that ``reference`` names, or ``None``.
184+
185+
A relative reference is relative to the bundle. A reference into an archive, such as
186+
``x.jar/member.py``, names the archive.
187+
"""
188+
bundle_path = Path(os.path.normpath(bundle_path))
189+
path = Path(os.path.normpath(bundle_path / reference))
190+
for candidate in (path, *path.parents):
191+
if not candidate.is_relative_to(bundle_path):
192+
return None
193+
if candidate.is_file():
194+
return candidate
195+
return None
196+
197+
152198
def _get_listed_path(
153-
registry: DagImporterRegistry, importer: AbstractDagImporter[Any], item: object
199+
registry: DagImporterRegistry, importer: AbstractDagImporter[Any], item: object, bundle_path: Path
154200
) -> Path | None:
155201
"""Return the file to parse for a listed item, or ``None`` when ``importer`` does not own one."""
156202
if isinstance(item, DagImportError):
157203
log.warning("Dag discovery error: %s", item.format_message())
158204
# Parsing the file lists it again and records the error as its import error.
159-
path = Path(item.source_reference)
160-
if not path.is_file():
161-
return None
205+
reference = Path(item.source_reference)
162206
elif isinstance(item, ZipMemberDagDefinition):
163-
path = item.zip_path
207+
reference = item.zip_path
164208
elif isinstance(item, FilesystemDagDefinition):
165-
path = item.path
209+
reference = item.path
166210
else:
167211
log.warning("Skipping %r: %s did not list a file", item, type(importer).__name__)
168212
return None
169-
return path if _get_claiming_importer_or_none(registry, path) is importer else None
213+
path = _find_bundle_file(bundle_path, reference)
214+
if path is None or _get_claiming_importer_or_none(registry, path) is not importer:
215+
return None
216+
return path
170217

171218

172219
def merge_claimed_paths(
@@ -184,13 +231,13 @@ def merge_claimed_paths(
184231
or raises while listing, every file with its extensions is kept: parsing each one then reports
185232
the failure, instead of the files' Dags being treated as deleted.
186233
"""
187-
bundle_path = _cast_to_bundle(bundle).path
234+
bundle_path = Path(_cast_to_bundle(bundle).path)
188235
claimed_paths: dict[str, None] = {}
189236
for importer, extensions in _group_claiming_importers(registry):
190237
if importer is not None:
191238
try:
192239
for item in importer.list_dag_definitions(_cast_to_bundle(bundle), safe_mode=safe_mode):
193-
if (path := _get_listed_path(registry, importer, item)) is not None:
240+
if (path := _get_listed_path(registry, importer, item, bundle_path)) is not None:
194241
claimed_paths.setdefault(os.fspath(path))
195242
continue
196243
except Exception:
@@ -235,7 +282,9 @@ def iter_claimed_results(
235282
bundle = _cast_to_bundle(BundleRef(name=bundle_name, path=bundle_path))
236283
for item in items:
237284
if isinstance(item, DagImportError):
238-
yield DagImportResult(errors=[item])
285+
# The listing root is the file itself, so a relative reference is relative to it.
286+
reference = os.fspath(file_path / item.source_reference)
287+
yield DagImportResult(errors=[dataclasses.replace(item, source_reference=reference)])
239288
continue
240289
try:
241290
result = importer.import_definition(item, bundle)

‎airflow-core/tests/unit/dag_processing/fake_importers.py‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@
2121

2222
import contextlib
2323
import json
24+
import os
2425
import zipfile
2526
from typing import TYPE_CHECKING, Any
2627

2728
from airflow.sdk import DAG, BaseOperator
2829
from airflow.sdk.importers import (
2930
AbstractDagImporter,
31+
DagDefinition,
3032
DagImportError,
3133
DagImportResult,
3234
DagImportWarning,
@@ -48,6 +50,8 @@
4850
FAILING_SOURCE_JAR_IMPORTER = f"{__name__}.FailingSourceJarImporter"
4951
ERROR_LISTING_JAR_IMPORTER = f"{__name__}.ErrorListingJarImporter"
5052
RAISING_LISTING_JAR_IMPORTER = f"{__name__}.RaisingListingJarImporter"
53+
MEMBER_ERROR_LISTING_JAR_IMPORTER = f"{__name__}.MemberErrorListingJarImporter"
54+
NON_FILE_LISTING_JAR_IMPORTER = f"{__name__}.NonFileListingJarImporter"
5155

5256

5357
def build_dag(dag_id: str, definition: FilesystemDagDefinition, bundle: Any) -> DAG:
@@ -124,15 +128,52 @@ def get_source_code(self, definition) -> DagSourceCode:
124128

125129

126130
class ErrorListingJarImporter(JarDagImporter):
127-
"""Report a JAR without ``dags.txt`` as a discovery error instead of skipping it."""
131+
"""
132+
Report a JAR without ``dags.txt`` as a discovery error instead of skipping it.
133+
134+
The error names the JAR relative to the listed root, as the Task SDK ``ZipImporter`` does.
135+
"""
136+
137+
member = ""
128138

129139
def list_dag_definitions(self, bundle, *, safe_mode=True) -> Iterator[Any]:
130140
for definition in find_file_dag_definitions(bundle.path, self.supported_extensions):
131141
with zipfile.ZipFile(definition.path) as jar:
132142
if "dags.txt" in jar.namelist():
133143
yield definition
134144
else:
135-
yield DagImportError(source_reference=repr(definition), message="no dags.txt")
145+
reference = os.path.join(definition.get_relative_loc(bundle.path), self.member)
146+
yield DagImportError(source_reference=os.path.normpath(reference), message="no dags.txt")
147+
148+
149+
class MemberErrorListingJarImporter(ErrorListingJarImporter):
150+
"""Report the discovery error against a member of the JAR, such as ``library.jar/Main.java``."""
151+
152+
member = "Main.java"
153+
154+
155+
class NonFileListingJarImporter(JarDagImporter):
156+
"""List a definition that is not a file next to the JARs."""
157+
158+
def list_dag_definitions(self, bundle, *, safe_mode=True) -> Iterator[Any]:
159+
yield NotAFileDagDefinition()
160+
yield from super().list_dag_definitions(bundle, safe_mode=safe_mode)
161+
162+
163+
class NotAFileDagDefinition(DagDefinition):
164+
freshness_token = ""
165+
166+
def get_relative_loc(self, root=None) -> str:
167+
return "not-a-file"
168+
169+
def read_bytes(self) -> bytes:
170+
return b""
171+
172+
def as_file(self):
173+
raise NotImplementedError
174+
175+
def __repr__(self) -> str:
176+
return "not-a-file"
136177

137178

138179
class RaisingListingJarImporter(JarDagImporter):

‎airflow-core/tests/unit/dag_processing/test_dagbag.py‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
from unit.dag_processing.fake_importers import (
6060
ERROR_LISTING_JAR_IMPORTER,
6161
FAKE_IMPORTER,
62+
MEMBER_ERROR_LISTING_JAR_IMPORTER,
6263
task_sdk_importers,
6364
write_jar,
6465
)
@@ -1582,15 +1583,41 @@ def test_importer_that_cannot_load_is_an_import_error(self, tmp_path):
15821583
in (dagbag.import_errors["claimed.fake"])
15831584
)
15841585

1585-
def test_discovery_error_is_an_import_error(self, tmp_path):
1586+
@pytest.mark.parametrize(
1587+
("importer_config", "error_key"),
1588+
[
1589+
pytest.param(ERROR_LISTING_JAR_IMPORTER, "library.jar", id="relative-to-the-file"),
1590+
pytest.param(MEMBER_ERROR_LISTING_JAR_IMPORTER, "library.jar/Main.java", id="archive-member"),
1591+
],
1592+
)
1593+
def test_single_file_discovery_error_is_keyed_by_the_file(self, tmp_path, importer_config, error_key):
15861594
library = write_jar(tmp_path / "library.jar")
15871595

1588-
with task_sdk_importers(ERROR_LISTING_JAR_IMPORTER):
1596+
with task_sdk_importers(importer_config):
15891597
dagbag = DagBag(dag_folder=os.fspath(library), bundle_path=tmp_path, bundle_name="testing")
15901598

15911599
assert dagbag.dags == {}
1600+
assert dagbag.import_errors == {error_key: "no dags.txt"}
1601+
1602+
def test_folder_discovery_error_is_an_import_error(self, tmp_path):
1603+
write_jar(tmp_path / "library.jar")
1604+
1605+
with task_sdk_importers(ERROR_LISTING_JAR_IMPORTER):
1606+
dagbag = DagBag(dag_folder=os.fspath(tmp_path), bundle_path=tmp_path, bundle_name="testing")
1607+
15921608
assert dagbag.import_errors == {"library.jar": "no dags.txt"}
15931609

1610+
def test_archive_member_importer_cannot_claim_files(self, tmp_path):
1611+
with zipfile.ZipFile(tmp_path / "packaged.jar", "w") as jar:
1612+
jar.writestr("zipped_dag.py", PY_DAG_SOURCE.format(dag_id="zipped_dag"))
1613+
1614+
with task_sdk_importers({"classpath": "airflow.sdk.importers.ZipImporter", "extensions": [".jar"]}):
1615+
dagbag = DagBag(dag_folder=os.fspath(tmp_path), bundle_path=tmp_path, bundle_name="testing")
1616+
1617+
assert dagbag.dags == {}
1618+
assert list(dagbag.import_errors) == ["packaged.jar"]
1619+
assert "ZipImporter cannot claim .jar files" in dagbag.import_errors["packaged.jar"]
1620+
15941621
def test_built_in_importer_claims_a_configured_extension(self, tmp_path):
15951622
claimed = tmp_path / "python_dag.dagpy"
15961623
claimed.write_text(PY_DAG_SOURCE.format(dag_id="python_dag"))
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
from __future__ import annotations
19+
20+
import logging
21+
22+
import pytest
23+
24+
from airflow.dag_processing.importer_routing import _find_bundle_file, get_task_sdk_registry
25+
26+
from unit.dag_processing.fake_importers import FAKE_IMPORTER, task_sdk_importers, write_jar
27+
28+
29+
@pytest.mark.parametrize(
30+
("reference", "expected"),
31+
[
32+
pytest.param("native.jar", "native.jar", id="relative-to-the-bundle"),
33+
pytest.param("native.jar/Main.java", "native.jar", id="archive-member"),
34+
pytest.param("{bundle}/native.jar", "native.jar", id="absolute-inside"),
35+
pytest.param("../outside.jar", None, id="relative-outside"),
36+
pytest.param("{root}/outside.jar", None, id="absolute-outside"),
37+
pytest.param("missing.jar", None, id="missing"),
38+
],
39+
)
40+
def test_find_bundle_file(tmp_path, reference, expected):
41+
bundle_path = tmp_path / "bundle"
42+
bundle_path.mkdir()
43+
write_jar(bundle_path / "native.jar", "native_dag")
44+
write_jar(tmp_path / "outside.jar", "outside_dag")
45+
46+
found = _find_bundle_file(
47+
bundle_path, bundle_path.joinpath(reference.format(bundle=bundle_path, root=tmp_path))
48+
)
49+
50+
assert found == (bundle_path / expected if expected else None)
51+
52+
53+
def test_legacy_extension_mapping_is_warned_once(caplog):
54+
with task_sdk_importers({"classpath": FAKE_IMPORTER, "extensions": [".fake", ".py"]}):
55+
with caplog.at_level(logging.WARNING, logger="airflow.dag_processing.importer_routing"):
56+
get_task_sdk_registry("testing")
57+
get_task_sdk_registry("testing")
58+
59+
warnings = [r.getMessage() for r in caplog.records if r.name == "airflow.dag_processing.importer_routing"]
60+
assert warnings == [
61+
"Ignoring the Dag importer configured for .py files: they always use the legacy importer"
62+
]

0 commit comments

Comments
 (0)