sparkforensics-operator 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sparkforensics_operator/__init__.py +29 -0
- sparkforensics_operator/_compat.py +23 -0
- sparkforensics_operator/callback.py +81 -0
- sparkforensics_operator/exceptions.py +10 -0
- sparkforensics_operator/hooks/__init__.py +0 -0
- sparkforensics_operator/hooks/analyze/__init__.py +0 -0
- sparkforensics_operator/hooks/analyze/base.py +18 -0
- sparkforensics_operator/hooks/analyze/subprocess.py +72 -0
- sparkforensics_operator/hooks/log_source/__init__.py +0 -0
- sparkforensics_operator/hooks/log_source/_dest_root.py +30 -0
- sparkforensics_operator/hooks/log_source/_path_template.py +31 -0
- sparkforensics_operator/hooks/log_source/_rolling_log.py +4 -0
- sparkforensics_operator/hooks/log_source/base.py +24 -0
- sparkforensics_operator/hooks/log_source/filesystem.py +49 -0
- sparkforensics_operator/hooks/log_source/history_server.py +99 -0
- sparkforensics_operator/hooks/log_source/sftp.py +107 -0
- sparkforensics_operator/hooks/log_source/tunnel.py +77 -0
- sparkforensics_operator/hooks/log_source/xcom.py +27 -0
- sparkforensics_operator/links.py +49 -0
- sparkforensics_operator/notify.py +37 -0
- sparkforensics_operator/operator.py +163 -0
- sparkforensics_operator/report.py +91 -0
- sparkforensics_operator/sinks.py +96 -0
- sparkforensics_operator-0.1.0.dist-info/METADATA +147 -0
- sparkforensics_operator-0.1.0.dist-info/RECORD +27 -0
- sparkforensics_operator-0.1.0.dist-info/WHEEL +4 -0
- sparkforensics_operator-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from sparkforensics_operator.callback import spark_forensics_callback
|
|
2
|
+
from sparkforensics_operator.exceptions import ThresholdBreached
|
|
3
|
+
from sparkforensics_operator.hooks.analyze.base import AnalyzeHook
|
|
4
|
+
from sparkforensics_operator.hooks.analyze.subprocess import SubprocessAnalyzeHook
|
|
5
|
+
from sparkforensics_operator.hooks.log_source.base import LogSourceHook
|
|
6
|
+
from sparkforensics_operator.hooks.log_source.filesystem import FilesystemLogSourceHook
|
|
7
|
+
from sparkforensics_operator.hooks.log_source.history_server import HistoryServerLogSourceHook
|
|
8
|
+
from sparkforensics_operator.hooks.log_source.sftp import SFTPLogSourceHook
|
|
9
|
+
from sparkforensics_operator.hooks.log_source.tunnel import SSHTunneledLogSourceHook
|
|
10
|
+
from sparkforensics_operator.hooks.log_source.xcom import XComLogSourceHook
|
|
11
|
+
from sparkforensics_operator.notify import Notifier
|
|
12
|
+
from sparkforensics_operator.operator import SparkForensicsOperator
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"SparkForensicsOperator",
|
|
18
|
+
"spark_forensics_callback",
|
|
19
|
+
"ThresholdBreached",
|
|
20
|
+
"LogSourceHook",
|
|
21
|
+
"HistoryServerLogSourceHook",
|
|
22
|
+
"FilesystemLogSourceHook",
|
|
23
|
+
"XComLogSourceHook",
|
|
24
|
+
"SFTPLogSourceHook",
|
|
25
|
+
"SSHTunneledLogSourceHook",
|
|
26
|
+
"AnalyzeHook",
|
|
27
|
+
"SubprocessAnalyzeHook",
|
|
28
|
+
"Notifier",
|
|
29
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Isolates the one real Airflow-2-vs-3 import difference this package depends
|
|
3
|
+
on. Airflow 3's Task SDK moved BaseOperator/BaseOperatorLink to the
|
|
4
|
+
airflow.sdk namespace; airflow.sdk does not exist on Airflow 2.x. Everywhere
|
|
5
|
+
else in this package, import BaseOperator/BaseOperatorLink from here, never
|
|
6
|
+
straight from airflow.
|
|
7
|
+
|
|
8
|
+
Within the Airflow 2.x branch there is a second split: BaseOperatorLink
|
|
9
|
+
moved out of airflow.models.baseoperator and into its own
|
|
10
|
+
airflow.models.baseoperatorlink module in Airflow 2.8.0. On Airflow
|
|
11
|
+
2.6.0/2.7.0, airflow.models.baseoperatorlink does not exist yet and
|
|
12
|
+
BaseOperatorLink must still be imported from airflow.models.baseoperator.
|
|
13
|
+
"""
|
|
14
|
+
try:
|
|
15
|
+
from airflow.sdk import BaseOperator, BaseOperatorLink
|
|
16
|
+
except ImportError: # Airflow 2.x has no airflow.sdk package
|
|
17
|
+
from airflow.models.baseoperator import BaseOperator
|
|
18
|
+
try:
|
|
19
|
+
from airflow.models.baseoperatorlink import BaseOperatorLink # Airflow >= 2.8
|
|
20
|
+
except ImportError: # Airflow 2.6/2.7
|
|
21
|
+
from airflow.models.baseoperator import BaseOperatorLink
|
|
22
|
+
|
|
23
|
+
__all__ = ["BaseOperator", "BaseOperatorLink"]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from sparkforensics_operator.exceptions import ThresholdBreached
|
|
7
|
+
from sparkforensics_operator.operator import run_spark_forensics
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger("airflow.task")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def spark_forensics_callback(
|
|
13
|
+
*,
|
|
14
|
+
log_source,
|
|
15
|
+
backend,
|
|
16
|
+
report_dest: str,
|
|
17
|
+
max_runtime_ms: int | None = None,
|
|
18
|
+
max_spill_gb: float | None = None,
|
|
19
|
+
max_skew_ratio: float | None = None,
|
|
20
|
+
max_failed_task_rate_pct: float | None = None,
|
|
21
|
+
min_efficiency_pct: float | None = None,
|
|
22
|
+
on_threshold_breach: str = "fail",
|
|
23
|
+
notifier=None,
|
|
24
|
+
aws_conn_id: str | None = None,
|
|
25
|
+
) -> Callable[[dict], None]:
|
|
26
|
+
"""Builds an on_success_callback for the upstream Spark task, sharing
|
|
27
|
+
SparkForensicsOperator's exact execute() logic via run_spark_forensics.
|
|
28
|
+
|
|
29
|
+
Note (see the plan's "Global constraints"): Airflow itself catches and
|
|
30
|
+
only logs any exception this callback raises, it never retries and
|
|
31
|
+
never fails the upstream task. Set on_threshold_breach="fail" here only
|
|
32
|
+
to get that log-and-continue behavior on breach; it will not fail the
|
|
33
|
+
DAG the way the standalone-operator trigger shape does.
|
|
34
|
+
"""
|
|
35
|
+
thresholds = {
|
|
36
|
+
"max_runtime_ms": max_runtime_ms,
|
|
37
|
+
"max_spill_gb": max_spill_gb,
|
|
38
|
+
"max_skew_ratio": max_skew_ratio,
|
|
39
|
+
"max_failed_task_rate_pct": max_failed_task_rate_pct,
|
|
40
|
+
"min_efficiency_pct": min_efficiency_pct,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
def _callback(context: dict) -> None:
|
|
44
|
+
ti = context.get("ti")
|
|
45
|
+
try:
|
|
46
|
+
destination = run_spark_forensics(
|
|
47
|
+
context,
|
|
48
|
+
log_source=log_source,
|
|
49
|
+
backend=backend,
|
|
50
|
+
report_dest=report_dest,
|
|
51
|
+
thresholds=thresholds,
|
|
52
|
+
on_threshold_breach=on_threshold_breach,
|
|
53
|
+
notifier=notifier,
|
|
54
|
+
log=log,
|
|
55
|
+
aws_conn_id=aws_conn_id,
|
|
56
|
+
)
|
|
57
|
+
except ThresholdBreached as e:
|
|
58
|
+
# on_threshold_breach="fail" (the default) makes run_spark_forensics
|
|
59
|
+
# raise ThresholdBreached *after* the report has already been
|
|
60
|
+
# persisted successfully. Without this, the callback path would
|
|
61
|
+
# never record the report's location on the one path (a breach)
|
|
62
|
+
# where a user most wants the report link. run_spark_forensics
|
|
63
|
+
# attaches the already-known-good destination to the exception
|
|
64
|
+
# for exactly this reason (see operator.py); still push it to
|
|
65
|
+
# XCom before letting the breach propagate as usual.
|
|
66
|
+
destination = getattr(e, "destination", None)
|
|
67
|
+
if ti and destination is not None:
|
|
68
|
+
ti.xcom_push(key="return_value", value=destination)
|
|
69
|
+
raise
|
|
70
|
+
|
|
71
|
+
# No operator auto-pushes this callback's return value to XCom, so
|
|
72
|
+
# push it manually under the same key ("return_value") an operator's
|
|
73
|
+
# own return value would use (see links.py's _XCOM_RETURN_KEY),
|
|
74
|
+
# keeping the persisted report's location discoverable via
|
|
75
|
+
# ti.xcom_pull() on the upstream task. Guard against a malformed/test
|
|
76
|
+
# context missing "ti": this is a notification-adjacent side effect
|
|
77
|
+
# and must not turn an already-successful run into a new failure.
|
|
78
|
+
if ti:
|
|
79
|
+
ti.xcom_push(key="return_value", value=destination)
|
|
80
|
+
|
|
81
|
+
return _callback
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from airflow.exceptions import AirflowFailException
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ThresholdBreached(AirflowFailException):
|
|
5
|
+
"""Raised when a configured SparkForensics threshold is violated and
|
|
6
|
+
on_threshold_breach="fail". Extends AirflowFailException (not
|
|
7
|
+
AirflowException): a breach is a deterministic verdict from the
|
|
8
|
+
already-completed analysis, so retrying would just re-run the same
|
|
9
|
+
fetch+analyze cycle to reach the same answer, and should not consume
|
|
10
|
+
the task's configured retries."""
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from airflow.hooks.base import BaseHook
|
|
5
|
+
|
|
6
|
+
from sparkforensics_operator.report import Report
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AnalyzeHook(BaseHook, ABC):
|
|
10
|
+
"""Runs sparkforensics analysis over a local event log and returns a
|
|
11
|
+
Report. v1 ships one implementation (subprocess against the
|
|
12
|
+
sparkforensics-analyze CLI), see the plan's "Future: http/MCP
|
|
13
|
+
AnalyzeHook backend" section for why an HTTP backend was deferred."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def analyze(self, log_path: Path, thresholds: dict) -> Report:
|
|
17
|
+
"""thresholds keys: max_runtime_ms, max_spill_gb, max_skew_ratio,
|
|
18
|
+
max_failed_task_rate_pct, min_efficiency_pct (all optional)."""
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import tempfile
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from airflow.exceptions import AirflowException
|
|
6
|
+
|
|
7
|
+
from sparkforensics_operator.report import THRESHOLD_CLI_FLAGS, Report, parse_report_json, parse_threshold_results
|
|
8
|
+
|
|
9
|
+
from .base import AnalyzeHook
|
|
10
|
+
|
|
11
|
+
# Exit codes 0 (success), 1 (threshold violated), and 3 (thresholds inconclusive) per the CLI's documented exit-code contract; anything else (OOM-kill, wrapper failure, etc.) means --out file is not trustworthy.
|
|
12
|
+
_USABLE_OUT_FILE_EXIT_CODES = {0, 1, 3}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SubprocessAnalyzeHook(AnalyzeHook):
|
|
16
|
+
"""Shells out to `sparkforensics-analyze <log_path> --format json --out
|
|
17
|
+
<tmpfile> [threshold flags]`. Requires Node.js (engines
|
|
18
|
+
'>=22.18.0 <23.0.0 || >=23.6.0') and the sparkforensics npm package
|
|
19
|
+
installed on the worker, with sparkforensics-analyze resolvable on
|
|
20
|
+
PATH (or pass analyze_bin=<full path>)."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, analyze_bin: str = "sparkforensics-analyze", timeout: int = 900):
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.analyze_bin = analyze_bin
|
|
25
|
+
self.timeout = timeout
|
|
26
|
+
|
|
27
|
+
def _threshold_args(self, thresholds: dict) -> list:
|
|
28
|
+
args = []
|
|
29
|
+
for key, flag in THRESHOLD_CLI_FLAGS.items():
|
|
30
|
+
value = thresholds.get(key)
|
|
31
|
+
if value is not None:
|
|
32
|
+
args.extend([flag, str(value)])
|
|
33
|
+
return args
|
|
34
|
+
|
|
35
|
+
def analyze(self, log_path: Path, thresholds: dict) -> Report:
|
|
36
|
+
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as out_file:
|
|
37
|
+
out_path = Path(out_file.name)
|
|
38
|
+
|
|
39
|
+
args = [self.analyze_bin, str(log_path), "--format", "json", "--out", str(out_path)]
|
|
40
|
+
args.extend(self._threshold_args(thresholds))
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
try:
|
|
44
|
+
proc = subprocess.run(args, capture_output=True, text=True, timeout=self.timeout)
|
|
45
|
+
except FileNotFoundError as e:
|
|
46
|
+
raise AirflowException(
|
|
47
|
+
f"sparkforensics-analyze binary not found: {self.analyze_bin!r}. "
|
|
48
|
+
"Install the sparkforensics npm package on this worker, or pass "
|
|
49
|
+
"analyze_bin=<full path to sparkforensics-analyze>."
|
|
50
|
+
) from e
|
|
51
|
+
except subprocess.TimeoutExpired as e:
|
|
52
|
+
raise AirflowException(
|
|
53
|
+
f"sparkforensics-analyze timed out after {self.timeout}s analyzing {log_path}."
|
|
54
|
+
) from e
|
|
55
|
+
|
|
56
|
+
if proc.returncode == 2:
|
|
57
|
+
raise AirflowException(
|
|
58
|
+
f"sparkforensics-analyze failed to parse the event log or was given "
|
|
59
|
+
f"bad arguments (exit 2): {proc.stderr.strip()}"
|
|
60
|
+
)
|
|
61
|
+
if proc.returncode not in _USABLE_OUT_FILE_EXIT_CODES:
|
|
62
|
+
raise AirflowException(
|
|
63
|
+
f"sparkforensics-analyze exited with unexpected code {proc.returncode}: "
|
|
64
|
+
f"{proc.stderr.strip()}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
report = parse_report_json(out_path)
|
|
68
|
+
report.threshold_results = parse_threshold_results(thresholds, proc.stderr)
|
|
69
|
+
report.exit_code = proc.returncode
|
|
70
|
+
return report
|
|
71
|
+
finally:
|
|
72
|
+
out_path.unlink(missing_ok=True)
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def dest_for(dest_root: Path, source_path: str | Path) -> Path:
|
|
9
|
+
"""The dest_root/<basename> convention shared by every LogSourceHook that
|
|
10
|
+
stages a single file: FilesystemLogSourceHook (local copy) and
|
|
11
|
+
SFTPLogSourceHook (remote-to-local fetch)."""
|
|
12
|
+
return dest_root / Path(source_path).name
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def make_dest_root(dest_dir: str | None) -> tuple[Path, Path | None]:
|
|
16
|
+
"""Resolves the directory a hook stages files into: dest_dir if the
|
|
17
|
+
caller gave one (a shared, caller-managed directory — the second tuple
|
|
18
|
+
element is None so cleanup() never touches it), else a fresh private
|
|
19
|
+
temp dir this hook owns (returned as both elements, so the caller can
|
|
20
|
+
rmtree it later, on error or in cleanup())."""
|
|
21
|
+
if dest_dir is not None:
|
|
22
|
+
root = Path(dest_dir)
|
|
23
|
+
return root, None
|
|
24
|
+
owned = Path(tempfile.mkdtemp(prefix="sparkforensics-"))
|
|
25
|
+
return owned, owned
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def remove_if_owned(owned_temp_root: Path | None) -> None:
|
|
29
|
+
if owned_temp_root is not None:
|
|
30
|
+
shutil.rmtree(owned_temp_root, ignore_errors=True)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _safe_path_component(value: object) -> str | None:
|
|
7
|
+
"""Reduce an attacker-influenceable context string (e.g. run_id, which is
|
|
8
|
+
settable via Airflow's trigger API) to its filesystem basename before it
|
|
9
|
+
is interpolated into path_template, the same way history_server.py
|
|
10
|
+
sanitizes app_id. None is preserved as None rather than becoming the
|
|
11
|
+
literal string "None"."""
|
|
12
|
+
if value is None:
|
|
13
|
+
return None
|
|
14
|
+
return Path(str(value)).name
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _template_vars(context: dict) -> dict:
|
|
18
|
+
dag = context.get("dag")
|
|
19
|
+
task = context.get("task")
|
|
20
|
+
return {
|
|
21
|
+
"ds": _safe_path_component(context.get("ds")),
|
|
22
|
+
"run_id": _safe_path_component(context.get("run_id")),
|
|
23
|
+
# Not sanitized: logical_date is datetime-like and path_template may
|
|
24
|
+
# apply a strftime format spec to it (e.g. "{logical_date:%Y-%m-%d}");
|
|
25
|
+
# Path(...).name would break that formatting and, unlike a plain
|
|
26
|
+
# string substitution, .format()'s format-spec mini-language for a
|
|
27
|
+
# datetime doesn't accept arbitrary attacker strings.
|
|
28
|
+
"logical_date": context.get("logical_date"),
|
|
29
|
+
"dag_id": _safe_path_component(dag.dag_id) if dag is not None else None,
|
|
30
|
+
"task_id": _safe_path_component(task.task_id) if task is not None else None,
|
|
31
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from airflow.hooks.base import BaseHook
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LogSourceHook(BaseHook, ABC):
|
|
8
|
+
"""Fetches a Spark job's event log to a local path. Implementations
|
|
9
|
+
differ only in where the log comes from: a Spark History Server, a
|
|
10
|
+
filesystem/HDFS path pattern, or an XCom value pushed by the upstream
|
|
11
|
+
Spark task.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def fetch(self, context: dict) -> Path:
|
|
16
|
+
"""Return a local path to the event log: either a single file, or a
|
|
17
|
+
directory of rolling-log segments (files named events_<n>_...)."""
|
|
18
|
+
|
|
19
|
+
def cleanup(self, path: Path) -> None:
|
|
20
|
+
"""Called after analysis, on success or failure. No-op by default:
|
|
21
|
+
override only to remove a path this hook created for itself (e.g. a
|
|
22
|
+
private temp dir); never one it merely handed back to the caller,
|
|
23
|
+
such as a user-owned filesystem path or another task's
|
|
24
|
+
XCom-referenced file."""
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from airflow.exceptions import AirflowException
|
|
7
|
+
|
|
8
|
+
from ._dest_root import dest_for
|
|
9
|
+
from ._path_template import _template_vars
|
|
10
|
+
from .base import LogSourceHook
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FilesystemLogSourceHook(LogSourceHook):
|
|
14
|
+
"""Reads an event log from a configured filesystem/HDFS path pattern.
|
|
15
|
+
"HDFS" here means an already-mounted path (NFS gateway, WebHDFS FUSE
|
|
16
|
+
mount, or similar), this hook does no Hadoop-client I/O of its own, it
|
|
17
|
+
just reads/copies a local path, the same way FilesystemLogSourceHook's
|
|
18
|
+
output is handed straight to sparkforensics-analyze's own local-file
|
|
19
|
+
reader."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, path_template: str, dest_dir: str | None = None):
|
|
22
|
+
super().__init__()
|
|
23
|
+
self.path_template = path_template
|
|
24
|
+
self.dest_dir = dest_dir
|
|
25
|
+
|
|
26
|
+
def fetch(self, context: dict) -> Path:
|
|
27
|
+
rendered = self.path_template.format(**_template_vars(context))
|
|
28
|
+
source = Path(rendered)
|
|
29
|
+
if not source.exists():
|
|
30
|
+
raise AirflowException(f"Configured log path does not exist: {source}")
|
|
31
|
+
if self.dest_dir is None:
|
|
32
|
+
return source
|
|
33
|
+
|
|
34
|
+
dest_root = Path(self.dest_dir)
|
|
35
|
+
dest_root.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
dest = dest_for(dest_root, source)
|
|
37
|
+
if source.is_dir():
|
|
38
|
+
shutil.copytree(source, dest, dirs_exist_ok=True)
|
|
39
|
+
else:
|
|
40
|
+
shutil.copy2(source, dest)
|
|
41
|
+
return dest
|
|
42
|
+
|
|
43
|
+
def cleanup(self, path: Path) -> None:
|
|
44
|
+
if self.dest_dir is None:
|
|
45
|
+
return # fetch() returned the caller's own path; never delete it.
|
|
46
|
+
if path.is_dir():
|
|
47
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
48
|
+
else:
|
|
49
|
+
path.unlink(missing_ok=True)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import zipfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
from airflow.exceptions import AirflowException
|
|
10
|
+
|
|
11
|
+
from ._dest_root import make_dest_root, remove_if_owned
|
|
12
|
+
from ._rolling_log import _ROLLING_ENTRY_RE
|
|
13
|
+
from .base import LogSourceHook
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HistoryServerLogSourceHook(LogSourceHook):
|
|
17
|
+
"""Downloads a Spark job's event log from a Spark History Server's REST
|
|
18
|
+
API: GET {base_url}/api/v1/applications/{app_id}[/{attempt_id}]/logs,
|
|
19
|
+
which always returns a zip (one entry for a single event-log file,
|
|
20
|
+
multiple events_<n>_... entries for a rolling log)."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
base_url: str,
|
|
25
|
+
app_id: str,
|
|
26
|
+
attempt_id: str | None = None,
|
|
27
|
+
dest_dir: str | None = None,
|
|
28
|
+
timeout: int = 300,
|
|
29
|
+
):
|
|
30
|
+
super().__init__()
|
|
31
|
+
self.base_url = base_url.rstrip("/")
|
|
32
|
+
self.app_id = app_id
|
|
33
|
+
self.attempt_id = attempt_id
|
|
34
|
+
self.dest_dir = dest_dir
|
|
35
|
+
self.timeout = timeout
|
|
36
|
+
self._owned_temp_root: Path | None = None
|
|
37
|
+
|
|
38
|
+
def _build_url(self) -> str:
|
|
39
|
+
segments = ["api", "v1", "applications", quote(self.app_id, safe="")]
|
|
40
|
+
if self.attempt_id:
|
|
41
|
+
segments.append(quote(self.attempt_id, safe=""))
|
|
42
|
+
segments.append("logs")
|
|
43
|
+
return f"{self.base_url}/{'/'.join(segments)}"
|
|
44
|
+
|
|
45
|
+
def fetch(self, context: dict) -> Path:
|
|
46
|
+
url = self._build_url()
|
|
47
|
+
response = requests.get(url, timeout=self.timeout, stream=True)
|
|
48
|
+
if response.status_code != 200:
|
|
49
|
+
raise AirflowException(
|
|
50
|
+
f"Spark History Server log download failed ({response.status_code}) for {url}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
dest_root, self._owned_temp_root = make_dest_root(self.dest_dir)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
dest_root.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
|
|
58
|
+
# self.app_id is only safe as a URL segment (quoted above); sanitize
|
|
59
|
+
# it before reusing it as a filesystem path component so a
|
|
60
|
+
# crafted/unexpected app_id (e.g. containing "../") can't escape
|
|
61
|
+
# dest_root.
|
|
62
|
+
safe_app_id = Path(self.app_id).name
|
|
63
|
+
zip_path = dest_root / f"{safe_app_id}.zip"
|
|
64
|
+
# self.timeout only bounds each individual socket read/connect (see
|
|
65
|
+
# the requests.get call above); a slow-trickling connection could
|
|
66
|
+
# otherwise keep the download running far longer than self.timeout
|
|
67
|
+
# implies. Track an explicit wall-clock deadline so self.timeout
|
|
68
|
+
# bounds the entire transfer, not just each chunk read.
|
|
69
|
+
deadline = time.monotonic() + self.timeout
|
|
70
|
+
with open(zip_path, "wb") as fh:
|
|
71
|
+
for chunk in response.iter_content(chunk_size=1024 * 1024):
|
|
72
|
+
fh.write(chunk)
|
|
73
|
+
if time.monotonic() > deadline:
|
|
74
|
+
response.close()
|
|
75
|
+
raise AirflowException(
|
|
76
|
+
f"Spark History Server log download for app {self.app_id} "
|
|
77
|
+
f"exceeded {self.timeout}s (total transfer time, not just a "
|
|
78
|
+
"single read/connect)."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
extract_dir = dest_root / safe_app_id
|
|
82
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
83
|
+
names = zf.namelist()
|
|
84
|
+
zf.extractall(extract_dir)
|
|
85
|
+
zip_path.unlink()
|
|
86
|
+
|
|
87
|
+
if len(names) == 1:
|
|
88
|
+
return extract_dir / names[0]
|
|
89
|
+
if any(_ROLLING_ENTRY_RE.match(Path(name).name) for name in names):
|
|
90
|
+
return extract_dir
|
|
91
|
+
raise AirflowException(
|
|
92
|
+
f"Unexpected Spark History Server log archive contents for app {self.app_id}: {names}"
|
|
93
|
+
)
|
|
94
|
+
except Exception:
|
|
95
|
+
remove_if_owned(self._owned_temp_root)
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
def cleanup(self, path: Path) -> None:
|
|
99
|
+
remove_if_owned(self._owned_temp_root)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import concurrent.futures
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from airflow.exceptions import AirflowException
|
|
7
|
+
|
|
8
|
+
from ._dest_root import dest_for, make_dest_root, remove_if_owned
|
|
9
|
+
from ._path_template import _template_vars
|
|
10
|
+
from ._rolling_log import _ROLLING_ENTRY_RE
|
|
11
|
+
from .base import LogSourceHook
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SFTPLogSourceHook(LogSourceHook):
|
|
15
|
+
"""Reads an event log from an on-prem filesystem/HDFS path pattern the
|
|
16
|
+
cloud Airflow worker can't mount directly, over the same SSH connection
|
|
17
|
+
already configured for SSHOperator (via SFTPHook). The SSH-reachable
|
|
18
|
+
equivalent of FilesystemLogSourceHook: unlike that hook, every fetch is
|
|
19
|
+
a remote-to-local copy. Same cleanup() convention as
|
|
20
|
+
HistoryServerLogSourceHook (not FilesystemLogSourceHook): the path is
|
|
21
|
+
auto-cleaned when dest_dir is None (a private temp dir this hook
|
|
22
|
+
owns), but is a no-op when dest_dir is set (a caller-managed shared
|
|
23
|
+
directory)."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, ssh_conn_id: str, path_template: str, dest_dir: str | None = None):
|
|
26
|
+
super().__init__()
|
|
27
|
+
self.ssh_conn_id = ssh_conn_id
|
|
28
|
+
self.path_template = path_template
|
|
29
|
+
self.dest_dir = dest_dir
|
|
30
|
+
self._owned_temp_root: Path | None = None
|
|
31
|
+
|
|
32
|
+
def fetch(self, context: dict) -> Path:
|
|
33
|
+
from airflow.providers.sftp.hooks.sftp import SFTPHook
|
|
34
|
+
|
|
35
|
+
remote_path = self.path_template.format(**_template_vars(context))
|
|
36
|
+
dest_root, self._owned_temp_root = make_dest_root(self.dest_dir)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
sftp_hook = SFTPHook(ssh_conn_id=self.ssh_conn_id)
|
|
40
|
+
if not sftp_hook.path_exists(remote_path):
|
|
41
|
+
raise AirflowException(
|
|
42
|
+
f"Configured log path does not exist (checked over SFTP via "
|
|
43
|
+
f"ssh_conn_id={self.ssh_conn_id!r}): {remote_path}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
dest_root.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
|
|
48
|
+
if sftp_hook.isdir(remote_path):
|
|
49
|
+
entries = sftp_hook.list_directory(remote_path) or []
|
|
50
|
+
rolling_entries = [e for e in entries if _ROLLING_ENTRY_RE.match(Path(e).name)]
|
|
51
|
+
if not rolling_entries:
|
|
52
|
+
raise AirflowException(
|
|
53
|
+
f"Unexpected SFTP log directory contents (not a rolling-log "
|
|
54
|
+
f"layout) at {remote_path!r} (checked over SFTP via "
|
|
55
|
+
f"ssh_conn_id={self.ssh_conn_id!r}): {entries}"
|
|
56
|
+
)
|
|
57
|
+
source_name = Path(remote_path).name
|
|
58
|
+
if not source_name:
|
|
59
|
+
raise AirflowException(
|
|
60
|
+
f"path_template resolved to a remote directory with no name "
|
|
61
|
+
f"component to stage locally under dest_dir: {remote_path!r}"
|
|
62
|
+
)
|
|
63
|
+
# Isolate per-source, same convention as FilesystemLogSourceHook's
|
|
64
|
+
# dest_root / source.name: a caller-supplied dest_dir shared across
|
|
65
|
+
# runs must not mix one run's events_<n>_* segments with another's.
|
|
66
|
+
source_dir = dest_root / source_name
|
|
67
|
+
source_dir.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
|
|
69
|
+
def _download(entry: str) -> None:
|
|
70
|
+
# Re-derive the basename that already passed the
|
|
71
|
+
# _ROLLING_ENTRY_RE check above and use it (not the raw
|
|
72
|
+
# entry) for the local destination, so a malformed
|
|
73
|
+
# directory entry can't escape source_dir.
|
|
74
|
+
local_name = Path(entry).name
|
|
75
|
+
remote_file = f"{remote_path.rstrip('/')}/{entry}"
|
|
76
|
+
sftp_hook.retrieve_file(remote_file, str(source_dir / local_name))
|
|
77
|
+
|
|
78
|
+
# Segments are independent files over the same SSH connection;
|
|
79
|
+
# overlapping the transfers cuts wall-clock time for
|
|
80
|
+
# multi-segment rolling logs versus one-at-a-time downloads.
|
|
81
|
+
with concurrent.futures.ThreadPoolExecutor(
|
|
82
|
+
max_workers=min(8, len(rolling_entries))
|
|
83
|
+
) as pool:
|
|
84
|
+
list(pool.map(_download, rolling_entries))
|
|
85
|
+
return source_dir
|
|
86
|
+
|
|
87
|
+
source_name = Path(remote_path).name
|
|
88
|
+
if not source_name:
|
|
89
|
+
raise AirflowException(
|
|
90
|
+
f"path_template resolved to a remote path with no filename "
|
|
91
|
+
f"component to stage locally under dest_dir: {remote_path!r}"
|
|
92
|
+
)
|
|
93
|
+
local_path = dest_for(dest_root, remote_path)
|
|
94
|
+
sftp_hook.retrieve_file(remote_path, str(local_path))
|
|
95
|
+
return local_path
|
|
96
|
+
except AirflowException:
|
|
97
|
+
remove_if_owned(self._owned_temp_root)
|
|
98
|
+
raise
|
|
99
|
+
except Exception as e:
|
|
100
|
+
remove_if_owned(self._owned_temp_root)
|
|
101
|
+
raise AirflowException(
|
|
102
|
+
f"SFTP log fetch failed (ssh_conn_id={self.ssh_conn_id!r}, "
|
|
103
|
+
f"remote_path={remote_path!r}): {e}"
|
|
104
|
+
) from e
|
|
105
|
+
|
|
106
|
+
def cleanup(self, path: Path) -> None:
|
|
107
|
+
remove_if_owned(self._owned_temp_root)
|