vigie-spark-operator 0.1.2__tar.gz
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.
- vigie_spark_operator-0.1.2/LICENSE +21 -0
- vigie_spark_operator-0.1.2/PKG-INFO +154 -0
- vigie_spark_operator-0.1.2/README.md +121 -0
- vigie_spark_operator-0.1.2/pyproject.toml +45 -0
- vigie_spark_operator-0.1.2/setup.cfg +4 -0
- vigie_spark_operator-0.1.2/tests/test_compat_af2_af3.py +90 -0
- vigie_spark_operator-0.1.2/tests/test_operator.py +219 -0
- vigie_spark_operator-0.1.2/vigie_spark_operator/__init__.py +16 -0
- vigie_spark_operator-0.1.2/vigie_spark_operator/compat.py +77 -0
- vigie_spark_operator-0.1.2/vigie_spark_operator/labels.py +63 -0
- vigie_spark_operator-0.1.2/vigie_spark_operator/models.py +50 -0
- vigie_spark_operator-0.1.2/vigie_spark_operator/operator.py +645 -0
- vigie_spark_operator-0.1.2/vigie_spark_operator/retry.py +40 -0
- vigie_spark_operator-0.1.2/vigie_spark_operator/shutdown.py +86 -0
- vigie_spark_operator-0.1.2/vigie_spark_operator.egg-info/PKG-INFO +154 -0
- vigie_spark_operator-0.1.2/vigie_spark_operator.egg-info/SOURCES.txt +17 -0
- vigie_spark_operator-0.1.2/vigie_spark_operator.egg-info/dependency_links.txt +1 -0
- vigie_spark_operator-0.1.2/vigie_spark_operator.egg-info/requires.txt +10 -0
- vigie_spark_operator-0.1.2/vigie_spark_operator.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HEMA Tiemoko Thierry
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vigie-spark-operator
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Airflow operator that submits Kubeflow SparkApplications on Kubernetes (AF2 + AF3)
|
|
5
|
+
Author: HEMA Tiemoko Thierry
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Jaali-co/vigie-spark-operator
|
|
8
|
+
Project-URL: Changelog, https://github.com/Jaali-co/vigie-spark-operator/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/Jaali-co/vigie-spark-operator/issues
|
|
10
|
+
Keywords: airflow,spark,kubernetes,spark-operator
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Framework :: Apache Airflow
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: kubernetes>=28.1.0
|
|
25
|
+
Provides-Extra: airflow
|
|
26
|
+
Requires-Dist: apache-airflow<4,>=2.7; extra == "airflow"
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-cov>=4.1; extra == "dev"
|
|
30
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
31
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# vigie-spark-operator
|
|
35
|
+
|
|
36
|
+
Open-source Airflow operator that submits a Kubeflow **SparkApplication**
|
|
37
|
+
(Spark Operator on Kubernetes). Works with **Airflow 2.x and 3.x**.
|
|
38
|
+
|
|
39
|
+
Maintainer: **HEMA Tiemoko Thierry** · License: **MIT**
|
|
40
|
+
|
|
41
|
+
## Requirements
|
|
42
|
+
|
|
43
|
+
| | |
|
|
44
|
+
|---|---|
|
|
45
|
+
| Python | `>=3.9` |
|
|
46
|
+
| Runtime dep | `kubernetes>=28.1.0` |
|
|
47
|
+
| Airflow (runtime) | `>=2.7,<4` — provided by the worker image, or install extra `[airflow]` |
|
|
48
|
+
| Cluster | [Kubeflow Spark Operator](https://github.com/kubeflow/spark-operator) CRD `SparkApplication` installed; RBAC for the Airflow worker SA |
|
|
49
|
+
|
|
50
|
+
Declared in `pyproject.toml`. Dev tools: see `requirements-dev.txt`.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install vigie-spark-operator
|
|
54
|
+
# or with Airflow pinned as dependency (usually not needed inside an AF image)
|
|
55
|
+
pip install "vigie-spark-operator[airflow]"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Quick start
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from vigie_spark_operator import SparkK8sOperator
|
|
62
|
+
|
|
63
|
+
SparkK8sOperator(
|
|
64
|
+
task_id="spark_pi",
|
|
65
|
+
name="pi",
|
|
66
|
+
namespace="spark-jobs",
|
|
67
|
+
image="spark:3.5.1",
|
|
68
|
+
application_type="Scala",
|
|
69
|
+
main_class="org.apache.spark.examples.SparkPi",
|
|
70
|
+
main_application_file="local:///opt/spark/examples/jars/spark-examples_2.12-3.5.1.jar",
|
|
71
|
+
arguments=["10"],
|
|
72
|
+
spark_version="3.5.1",
|
|
73
|
+
service_account="spark",
|
|
74
|
+
dry_run=False,
|
|
75
|
+
)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Optional Vigie capacity labels
|
|
79
|
+
|
|
80
|
+
Generic by default (no `managed-by`). For Vigie Supervision, pass
|
|
81
|
+
`managed_by="vigie"` so driver/executor get `managed-by=vigie` plus
|
|
82
|
+
`dag_id` / `task_id` / `run_id`:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
SparkK8sOperator(..., managed_by="vigie")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Disable context labels entirely: `inject_airflow_labels=False`.
|
|
89
|
+
|
|
90
|
+
## Install on Airflow workers
|
|
91
|
+
|
|
92
|
+
Preferred: bake the wheel into the worker image.
|
|
93
|
+
|
|
94
|
+
Alternative: `_PIP_ADDITIONAL_REQUIREMENTS=vigie-spark-operator==0.1.2` (slower, less reproducible).
|
|
95
|
+
|
|
96
|
+
AF2 / AF3: the package selects `airflow.models` (AF2) or `airflow.sdk` (AF3) via `compat.py`.
|
|
97
|
+
|
|
98
|
+
## Main parameters
|
|
99
|
+
|
|
100
|
+
| Parameter | Default | Notes |
|
|
101
|
+
|-----------|---------|--------|
|
|
102
|
+
| `name` | *(required)* | Base name for the SparkApplication |
|
|
103
|
+
| `namespace` | *(required)* | Target K8s namespace |
|
|
104
|
+
| `image` | *(required)* | Spark runtime image |
|
|
105
|
+
| `main_application_file` | *(required)* | App entry (`local://…`, `s3a://…`, …) |
|
|
106
|
+
| `application_type` | `"Python"` | `Python` / `Scala` / `Java` / `R` |
|
|
107
|
+
| `main_class` | `None` | Required for Scala/Java |
|
|
108
|
+
| `arguments` | `[]` | App args |
|
|
109
|
+
| `spark_version` | `"3.1.1"` | Reported to the Spark Operator |
|
|
110
|
+
| `driver_cores` / `driver_memory` | `1` / `"512m"` | Driver resources |
|
|
111
|
+
| `driver_cores_request` | `"20m"` | K8s CPU request |
|
|
112
|
+
| `executor_cores` / `executor_memory` | `1` / `"512m"` | Executor resources |
|
|
113
|
+
| `executor_cores_request` | `"400m"` | K8s CPU request |
|
|
114
|
+
| `executor_instances` | `2` | Number of executors |
|
|
115
|
+
| `service_account` | `"spark"` | Pod SA in the job namespace |
|
|
116
|
+
| `spark_conf` | `{}` | Extra Spark conf |
|
|
117
|
+
| `labels` / `env` / `jars` | `{}` / `{}` / `[]` | Merged labels, env vars, extra jars |
|
|
118
|
+
| `pvc_configs` / `emptydir_configs` | `[]` | Volume mounts (`PvcConfig` / `EmptyDirConfig` or dicts) |
|
|
119
|
+
| `run_as_user` / `fs_group` | `0` / `0` | Shared security context |
|
|
120
|
+
| `allow_privilege_escalation` | `False` | Shared; optional `driver_*` / `executor_*` overrides |
|
|
121
|
+
| `timeout_job` | `3600` | Job timeout (s); graceful delete then force-kill |
|
|
122
|
+
| `timeout` | `None` | Legacy alias → sets `timeout_job` |
|
|
123
|
+
| `poll_interval` | `10` | Status poll (s) |
|
|
124
|
+
| `api_error_retries` | `5` | Retries on K8s `429` / `503` |
|
|
125
|
+
| `in_cluster` | `True` | Use in-cluster kubeconfig |
|
|
126
|
+
| `fail_on_unschedulable` | `True` | Fail if pods stay Pending |
|
|
127
|
+
| `dry_run` | `False` | Build + log manifest, no submit |
|
|
128
|
+
| `inject_airflow_labels` | `True` | Inject `dag_id` / `task_id` / `run_id` |
|
|
129
|
+
| `managed_by` | `None` | e.g. `"vigie"` for capacity collector |
|
|
130
|
+
|
|
131
|
+
Also accepts standard `BaseOperator` kwargs (`task_id`, `queue`, `executor`, …).
|
|
132
|
+
|
|
133
|
+
## Behaviour highlights
|
|
134
|
+
|
|
135
|
+
- Labels on **metadata, driver and executor**
|
|
136
|
+
- `dry_run=True`: validates resources summary, exits 0 without creating the CR
|
|
137
|
+
- Exponential backoff on transient K8s API errors (`429`, `503`)
|
|
138
|
+
- Near `timeout_job`: delete SparkApplication, then force-kill leftover pods
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
144
|
+
pip install -r requirements-dev.txt
|
|
145
|
+
pip install -e .
|
|
146
|
+
pytest -q
|
|
147
|
+
# packaging smoke (build → twine check → clean install)
|
|
148
|
+
../scripts/test_pypi_packaging.sh .
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Links
|
|
152
|
+
|
|
153
|
+
- Changelog: [CHANGELOG.md](CHANGELOG.md)
|
|
154
|
+
- Issues: https://github.com/Jaali-co/vigie-spark-operator/issues
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# vigie-spark-operator
|
|
2
|
+
|
|
3
|
+
Open-source Airflow operator that submits a Kubeflow **SparkApplication**
|
|
4
|
+
(Spark Operator on Kubernetes). Works with **Airflow 2.x and 3.x**.
|
|
5
|
+
|
|
6
|
+
Maintainer: **HEMA Tiemoko Thierry** · License: **MIT**
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
|
|
10
|
+
| | |
|
|
11
|
+
|---|---|
|
|
12
|
+
| Python | `>=3.9` |
|
|
13
|
+
| Runtime dep | `kubernetes>=28.1.0` |
|
|
14
|
+
| Airflow (runtime) | `>=2.7,<4` — provided by the worker image, or install extra `[airflow]` |
|
|
15
|
+
| Cluster | [Kubeflow Spark Operator](https://github.com/kubeflow/spark-operator) CRD `SparkApplication` installed; RBAC for the Airflow worker SA |
|
|
16
|
+
|
|
17
|
+
Declared in `pyproject.toml`. Dev tools: see `requirements-dev.txt`.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install vigie-spark-operator
|
|
21
|
+
# or with Airflow pinned as dependency (usually not needed inside an AF image)
|
|
22
|
+
pip install "vigie-spark-operator[airflow]"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from vigie_spark_operator import SparkK8sOperator
|
|
29
|
+
|
|
30
|
+
SparkK8sOperator(
|
|
31
|
+
task_id="spark_pi",
|
|
32
|
+
name="pi",
|
|
33
|
+
namespace="spark-jobs",
|
|
34
|
+
image="spark:3.5.1",
|
|
35
|
+
application_type="Scala",
|
|
36
|
+
main_class="org.apache.spark.examples.SparkPi",
|
|
37
|
+
main_application_file="local:///opt/spark/examples/jars/spark-examples_2.12-3.5.1.jar",
|
|
38
|
+
arguments=["10"],
|
|
39
|
+
spark_version="3.5.1",
|
|
40
|
+
service_account="spark",
|
|
41
|
+
dry_run=False,
|
|
42
|
+
)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Optional Vigie capacity labels
|
|
46
|
+
|
|
47
|
+
Generic by default (no `managed-by`). For Vigie Supervision, pass
|
|
48
|
+
`managed_by="vigie"` so driver/executor get `managed-by=vigie` plus
|
|
49
|
+
`dag_id` / `task_id` / `run_id`:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
SparkK8sOperator(..., managed_by="vigie")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Disable context labels entirely: `inject_airflow_labels=False`.
|
|
56
|
+
|
|
57
|
+
## Install on Airflow workers
|
|
58
|
+
|
|
59
|
+
Preferred: bake the wheel into the worker image.
|
|
60
|
+
|
|
61
|
+
Alternative: `_PIP_ADDITIONAL_REQUIREMENTS=vigie-spark-operator==0.1.2` (slower, less reproducible).
|
|
62
|
+
|
|
63
|
+
AF2 / AF3: the package selects `airflow.models` (AF2) or `airflow.sdk` (AF3) via `compat.py`.
|
|
64
|
+
|
|
65
|
+
## Main parameters
|
|
66
|
+
|
|
67
|
+
| Parameter | Default | Notes |
|
|
68
|
+
|-----------|---------|--------|
|
|
69
|
+
| `name` | *(required)* | Base name for the SparkApplication |
|
|
70
|
+
| `namespace` | *(required)* | Target K8s namespace |
|
|
71
|
+
| `image` | *(required)* | Spark runtime image |
|
|
72
|
+
| `main_application_file` | *(required)* | App entry (`local://…`, `s3a://…`, …) |
|
|
73
|
+
| `application_type` | `"Python"` | `Python` / `Scala` / `Java` / `R` |
|
|
74
|
+
| `main_class` | `None` | Required for Scala/Java |
|
|
75
|
+
| `arguments` | `[]` | App args |
|
|
76
|
+
| `spark_version` | `"3.1.1"` | Reported to the Spark Operator |
|
|
77
|
+
| `driver_cores` / `driver_memory` | `1` / `"512m"` | Driver resources |
|
|
78
|
+
| `driver_cores_request` | `"20m"` | K8s CPU request |
|
|
79
|
+
| `executor_cores` / `executor_memory` | `1` / `"512m"` | Executor resources |
|
|
80
|
+
| `executor_cores_request` | `"400m"` | K8s CPU request |
|
|
81
|
+
| `executor_instances` | `2` | Number of executors |
|
|
82
|
+
| `service_account` | `"spark"` | Pod SA in the job namespace |
|
|
83
|
+
| `spark_conf` | `{}` | Extra Spark conf |
|
|
84
|
+
| `labels` / `env` / `jars` | `{}` / `{}` / `[]` | Merged labels, env vars, extra jars |
|
|
85
|
+
| `pvc_configs` / `emptydir_configs` | `[]` | Volume mounts (`PvcConfig` / `EmptyDirConfig` or dicts) |
|
|
86
|
+
| `run_as_user` / `fs_group` | `0` / `0` | Shared security context |
|
|
87
|
+
| `allow_privilege_escalation` | `False` | Shared; optional `driver_*` / `executor_*` overrides |
|
|
88
|
+
| `timeout_job` | `3600` | Job timeout (s); graceful delete then force-kill |
|
|
89
|
+
| `timeout` | `None` | Legacy alias → sets `timeout_job` |
|
|
90
|
+
| `poll_interval` | `10` | Status poll (s) |
|
|
91
|
+
| `api_error_retries` | `5` | Retries on K8s `429` / `503` |
|
|
92
|
+
| `in_cluster` | `True` | Use in-cluster kubeconfig |
|
|
93
|
+
| `fail_on_unschedulable` | `True` | Fail if pods stay Pending |
|
|
94
|
+
| `dry_run` | `False` | Build + log manifest, no submit |
|
|
95
|
+
| `inject_airflow_labels` | `True` | Inject `dag_id` / `task_id` / `run_id` |
|
|
96
|
+
| `managed_by` | `None` | e.g. `"vigie"` for capacity collector |
|
|
97
|
+
|
|
98
|
+
Also accepts standard `BaseOperator` kwargs (`task_id`, `queue`, `executor`, …).
|
|
99
|
+
|
|
100
|
+
## Behaviour highlights
|
|
101
|
+
|
|
102
|
+
- Labels on **metadata, driver and executor**
|
|
103
|
+
- `dry_run=True`: validates resources summary, exits 0 without creating the CR
|
|
104
|
+
- Exponential backoff on transient K8s API errors (`429`, `503`)
|
|
105
|
+
- Near `timeout_job`: delete SparkApplication, then force-kill leftover pods
|
|
106
|
+
|
|
107
|
+
## Development
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
111
|
+
pip install -r requirements-dev.txt
|
|
112
|
+
pip install -e .
|
|
113
|
+
pytest -q
|
|
114
|
+
# packaging smoke (build → twine check → clean install)
|
|
115
|
+
../scripts/test_pypi_packaging.sh .
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Links
|
|
119
|
+
|
|
120
|
+
- Changelog: [CHANGELOG.md](CHANGELOG.md)
|
|
121
|
+
- Issues: https://github.com/Jaali-co/vigie-spark-operator/issues
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vigie-spark-operator"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "Airflow operator that submits Kubeflow SparkApplications on Kubernetes (AF2 + AF3)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "HEMA Tiemoko Thierry" }]
|
|
13
|
+
keywords = ["airflow", "spark", "kubernetes", "spark-operator"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Framework :: Apache Airflow",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: System :: Distributed Computing",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"kubernetes>=28.1.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
airflow = ["apache-airflow>=2.7,<4"]
|
|
32
|
+
dev = ["pytest>=7.4", "pytest-cov>=4.1", "build>=1.2", "twine>=5.0"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/Jaali-co/vigie-spark-operator"
|
|
36
|
+
Changelog = "https://github.com/Jaali-co/vigie-spark-operator/blob/main/CHANGELOG.md"
|
|
37
|
+
Issues = "https://github.com/Jaali-co/vigie-spark-operator/issues"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
where = ["."]
|
|
41
|
+
include = ["vigie_spark_operator*"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests"]
|
|
45
|
+
pythonpath = ["."]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Tests for AF2/AF3 BaseOperator resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import sys
|
|
7
|
+
import types
|
|
8
|
+
from unittest.mock import MagicMock
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _install_af2_stub():
|
|
12
|
+
airflow = types.ModuleType("airflow")
|
|
13
|
+
airflow.__version__ = "2.10.5"
|
|
14
|
+
models = types.ModuleType("airflow.models")
|
|
15
|
+
|
|
16
|
+
class BaseOperator:
|
|
17
|
+
template_fields = ()
|
|
18
|
+
|
|
19
|
+
def __init__(self, **kwargs):
|
|
20
|
+
self.log = MagicMock()
|
|
21
|
+
|
|
22
|
+
class Variable:
|
|
23
|
+
@staticmethod
|
|
24
|
+
def get(*a, **k):
|
|
25
|
+
return {}
|
|
26
|
+
|
|
27
|
+
models.BaseOperator = BaseOperator
|
|
28
|
+
models.Variable = Variable
|
|
29
|
+
sys.modules["airflow"] = airflow
|
|
30
|
+
sys.modules["airflow.models"] = models
|
|
31
|
+
sys.modules.pop("airflow.sdk", None)
|
|
32
|
+
sys.modules.pop("vigie_spark_operator.compat", None)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _install_af3_stub():
|
|
36
|
+
airflow = types.ModuleType("airflow")
|
|
37
|
+
airflow.__version__ = "3.2.2"
|
|
38
|
+
sdk = types.ModuleType("airflow.sdk")
|
|
39
|
+
|
|
40
|
+
class BaseOperator:
|
|
41
|
+
template_fields = ()
|
|
42
|
+
|
|
43
|
+
def __init__(self, **kwargs):
|
|
44
|
+
self.log = MagicMock()
|
|
45
|
+
|
|
46
|
+
class Variable:
|
|
47
|
+
@staticmethod
|
|
48
|
+
def get(*a, **k):
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
sdk.BaseOperator = BaseOperator
|
|
52
|
+
sdk.Variable = Variable
|
|
53
|
+
models = types.ModuleType("airflow.models")
|
|
54
|
+
models.BaseOperator = object # must not be used on AF3 path
|
|
55
|
+
sys.modules["airflow"] = airflow
|
|
56
|
+
sys.modules["airflow.sdk"] = sdk
|
|
57
|
+
sys.modules["airflow.models"] = models
|
|
58
|
+
sys.modules.pop("vigie_spark_operator.compat", None)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_compat_af2_uses_models():
|
|
62
|
+
_install_af2_stub()
|
|
63
|
+
compat = importlib.import_module("vigie_spark_operator.compat")
|
|
64
|
+
importlib.reload(compat)
|
|
65
|
+
assert compat.AIRFLOW_V_3_0_PLUS is False
|
|
66
|
+
assert compat.BaseOperator is sys.modules["airflow.models"].BaseOperator
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_compat_af3_uses_sdk():
|
|
70
|
+
_install_af3_stub()
|
|
71
|
+
compat = importlib.import_module("vigie_spark_operator.compat")
|
|
72
|
+
importlib.reload(compat)
|
|
73
|
+
assert compat.AIRFLOW_V_3_0_PLUS is True
|
|
74
|
+
assert compat.BaseOperator is sys.modules["airflow.sdk"].BaseOperator
|
|
75
|
+
assert compat.Variable is sys.modules["airflow.sdk"].Variable
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_compat_af2_variable_lazy():
|
|
79
|
+
_install_af2_stub()
|
|
80
|
+
models = sys.modules["airflow.models"]
|
|
81
|
+
|
|
82
|
+
class Variable:
|
|
83
|
+
@staticmethod
|
|
84
|
+
def get(*a, **k):
|
|
85
|
+
return "x"
|
|
86
|
+
|
|
87
|
+
models.Variable = Variable
|
|
88
|
+
compat = importlib.import_module("vigie_spark_operator.compat")
|
|
89
|
+
importlib.reload(compat)
|
|
90
|
+
assert compat.Variable is Variable
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Tests unitaires — labels et dry_run SparkK8sOperator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import types
|
|
7
|
+
from types import SimpleNamespace
|
|
8
|
+
from unittest.mock import MagicMock
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
# Stub Airflow avant import de l'operator (pas besoin d'Airflow installé pour ces tests)
|
|
13
|
+
_airflow = types.ModuleType("airflow")
|
|
14
|
+
_models = types.ModuleType("airflow.models")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _FakeBaseOperator:
|
|
18
|
+
template_fields: tuple = ()
|
|
19
|
+
|
|
20
|
+
def __init__(self, **kwargs):
|
|
21
|
+
self.log = MagicMock()
|
|
22
|
+
for key, value in kwargs.items():
|
|
23
|
+
setattr(self, key, value)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_models.BaseOperator = _FakeBaseOperator
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _FakeVariable:
|
|
30
|
+
@staticmethod
|
|
31
|
+
def get(*_a, **_k):
|
|
32
|
+
return {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_models.Variable = _FakeVariable
|
|
36
|
+
sys.modules["airflow"] = _airflow
|
|
37
|
+
sys.modules["airflow.models"] = _models
|
|
38
|
+
|
|
39
|
+
# Stub kubernetes minimal (chargé au import operator)
|
|
40
|
+
_k8s = types.ModuleType("kubernetes")
|
|
41
|
+
_k8s_client = types.ModuleType("kubernetes.client")
|
|
42
|
+
_k8s_config = types.ModuleType("kubernetes.config")
|
|
43
|
+
_k8s_exc = types.ModuleType("kubernetes.client.exceptions")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _ApiException(Exception):
|
|
47
|
+
def __init__(self, status=500, reason="err"):
|
|
48
|
+
self.status = status
|
|
49
|
+
self.reason = reason
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_k8s_exc.ApiException = _ApiException
|
|
53
|
+
_k8s_client.exceptions = _k8s_exc
|
|
54
|
+
_k8s_client.CustomObjectsApi = MagicMock
|
|
55
|
+
_k8s_client.CoreV1Api = MagicMock
|
|
56
|
+
_k8s_client.V1DeleteOptions = MagicMock
|
|
57
|
+
_k8s.client = _k8s_client
|
|
58
|
+
_k8s.config = _k8s_config
|
|
59
|
+
sys.modules["kubernetes"] = _k8s
|
|
60
|
+
sys.modules["kubernetes.client"] = _k8s_client
|
|
61
|
+
sys.modules["kubernetes.config"] = _k8s_config
|
|
62
|
+
sys.modules["kubernetes.client.exceptions"] = _k8s_exc
|
|
63
|
+
|
|
64
|
+
# Force re-bind BaseOperator from our AF stub (compat may have been imported earlier).
|
|
65
|
+
for mod in (
|
|
66
|
+
"vigie_spark_operator.compat",
|
|
67
|
+
"vigie_spark_operator.operator",
|
|
68
|
+
"vigie_spark_operator",
|
|
69
|
+
):
|
|
70
|
+
sys.modules.pop(mod, None)
|
|
71
|
+
|
|
72
|
+
from vigie_spark_operator import SparkK8sOperator, __version__
|
|
73
|
+
from vigie_spark_operator.labels import airflow_labels, sanitize_label_value
|
|
74
|
+
from vigie_spark_operator.retry import k8s_call_with_retry
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _ctx(dag_id="my-dag", task_id="spark_pi", run_id="manual__2026-09-05T10:00:00+00:00"):
|
|
78
|
+
return {
|
|
79
|
+
"dag": SimpleNamespace(dag_id=dag_id),
|
|
80
|
+
"task": SimpleNamespace(task_id=task_id),
|
|
81
|
+
"run_id": run_id,
|
|
82
|
+
"ts_nodash": "20260905T100000",
|
|
83
|
+
"ti": SimpleNamespace(try_number=1),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_version():
|
|
88
|
+
assert __version__ == "0.1.2"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_sanitize_truncates_and_cleans():
|
|
92
|
+
long_run = "x" * 80
|
|
93
|
+
assert len(sanitize_label_value(long_run)) <= 63
|
|
94
|
+
assert sanitize_label_value("dag/with spaces!") == "dag-with-spaces"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_airflow_labels_from_context():
|
|
98
|
+
labels = airflow_labels(_ctx())
|
|
99
|
+
assert labels["dag_id"] == "my-dag"
|
|
100
|
+
assert labels["task_id"] == "spark_pi"
|
|
101
|
+
assert "managed-by" not in labels
|
|
102
|
+
assert len(labels["run_id"]) <= 63
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_airflow_labels_managed_by():
|
|
106
|
+
labels = airflow_labels(_ctx(), managed_by="vigie")
|
|
107
|
+
assert labels["managed-by"] == "vigie"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_security_defaults():
|
|
111
|
+
op = SparkK8sOperator(
|
|
112
|
+
task_id="spark_pi",
|
|
113
|
+
name="pi",
|
|
114
|
+
namespace="ns",
|
|
115
|
+
image="spark:3.5.1",
|
|
116
|
+
main_application_file="local:///x.py",
|
|
117
|
+
)
|
|
118
|
+
spec = op._build_spark_app_spec(_ctx())
|
|
119
|
+
for side in ("driver", "executor"):
|
|
120
|
+
assert spec["spec"][side]["securityContext"]["runAsUser"] == 0
|
|
121
|
+
assert spec["spec"][side]["podSecurityContext"]["fsGroup"] == 0
|
|
122
|
+
assert spec["spec"][side]["securityContext"]["allowPrivilegeEscalation"] is False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_security_overrides_shared_and_per_role():
|
|
126
|
+
op = SparkK8sOperator(
|
|
127
|
+
task_id="spark_pi",
|
|
128
|
+
name="pi",
|
|
129
|
+
namespace="ns",
|
|
130
|
+
image="spark:3.5.1",
|
|
131
|
+
main_application_file="local:///x.py",
|
|
132
|
+
run_as_user=185,
|
|
133
|
+
fs_group=185,
|
|
134
|
+
executor_run_as_user=1000,
|
|
135
|
+
)
|
|
136
|
+
spec = op._build_spark_app_spec(_ctx())
|
|
137
|
+
assert spec["spec"]["driver"]["securityContext"]["runAsUser"] == 185
|
|
138
|
+
assert spec["spec"]["driver"]["podSecurityContext"]["fsGroup"] == 185
|
|
139
|
+
assert spec["spec"]["executor"]["securityContext"]["runAsUser"] == 1000
|
|
140
|
+
assert spec["spec"]["executor"]["podSecurityContext"]["fsGroup"] == 185
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_build_spec_injects_labels_on_driver_and_executor():
|
|
144
|
+
op = SparkK8sOperator(
|
|
145
|
+
task_id="spark_pi",
|
|
146
|
+
name="pi",
|
|
147
|
+
namespace="airflow2-spark-jobs",
|
|
148
|
+
image="spark:3.5.1",
|
|
149
|
+
main_application_file="local:///opt/spark/examples/jars/x.jar",
|
|
150
|
+
application_type="Scala",
|
|
151
|
+
main_class="org.apache.spark.examples.SparkPi",
|
|
152
|
+
executor_instances=1,
|
|
153
|
+
managed_by="vigie",
|
|
154
|
+
)
|
|
155
|
+
spec = op._build_spark_app_spec(_ctx())
|
|
156
|
+
for side in ("driver", "executor"):
|
|
157
|
+
labels = spec["spec"][side]["labels"]
|
|
158
|
+
assert labels["dag_id"] == "my-dag"
|
|
159
|
+
assert labels["task_id"] == "spark_pi"
|
|
160
|
+
assert labels["managed-by"] == "vigie"
|
|
161
|
+
assert "run_id" in labels
|
|
162
|
+
assert spec["metadata"]["labels"]["dag_id"] == "my-dag"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_build_spec_without_managed_by_omits_label():
|
|
166
|
+
op = SparkK8sOperator(
|
|
167
|
+
task_id="spark_pi",
|
|
168
|
+
name="pi",
|
|
169
|
+
namespace="ns",
|
|
170
|
+
image="spark:3.5.1",
|
|
171
|
+
main_application_file="local:///x.py",
|
|
172
|
+
)
|
|
173
|
+
labels = op._build_spark_app_spec(_ctx())["spec"]["driver"]["labels"]
|
|
174
|
+
assert "managed-by" not in labels
|
|
175
|
+
assert labels["dag_id"] == "my-dag"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_dry_run_returns_summary_without_k8s():
|
|
179
|
+
op = SparkK8sOperator(
|
|
180
|
+
task_id="spark_pi",
|
|
181
|
+
name="pi",
|
|
182
|
+
namespace="airflow2-spark-jobs",
|
|
183
|
+
image="spark:3.5.1",
|
|
184
|
+
main_application_file="local:///x.py",
|
|
185
|
+
application_type="Python",
|
|
186
|
+
dry_run=True,
|
|
187
|
+
executor_instances=2,
|
|
188
|
+
driver_memory="1g",
|
|
189
|
+
executor_memory="2g",
|
|
190
|
+
)
|
|
191
|
+
# dry_run ne doit pas appeler _load_kube
|
|
192
|
+
op._load_kube = MagicMock(side_effect=AssertionError("k8s should not load"))
|
|
193
|
+
summary = op.execute(_ctx())
|
|
194
|
+
assert summary["driver"]["memory"] == "1g"
|
|
195
|
+
assert summary["executor"]["instances"] == 2
|
|
196
|
+
assert summary["labels"]["driver"]["dag_id"] == "my-dag"
|
|
197
|
+
op._load_kube.assert_not_called()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_k8s_retry_on_429():
|
|
201
|
+
calls = {"n": 0}
|
|
202
|
+
|
|
203
|
+
def flaky():
|
|
204
|
+
calls["n"] += 1
|
|
205
|
+
if calls["n"] < 3:
|
|
206
|
+
raise _ApiException(status=429, reason="too many")
|
|
207
|
+
return "ok"
|
|
208
|
+
|
|
209
|
+
assert k8s_call_with_retry(flaky, max_retries=3, base_delay=0.01) == "ok"
|
|
210
|
+
assert calls["n"] == 3
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_k8s_retry_gives_up_on_403():
|
|
214
|
+
def forbidden():
|
|
215
|
+
raise _ApiException(status=403, reason="nope")
|
|
216
|
+
|
|
217
|
+
with pytest.raises(_ApiException) as ei:
|
|
218
|
+
k8s_call_with_retry(forbidden, max_retries=3, base_delay=0.01)
|
|
219
|
+
assert ei.value.status == 403
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""vigie_spark_operator — SparkK8sOperator (Airflow → SparkApplication K8s)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.2"
|
|
8
|
+
__all__ = ["SparkK8sOperator", "__version__"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def __getattr__(name: str) -> Any:
|
|
12
|
+
if name == "SparkK8sOperator":
|
|
13
|
+
from vigie_spark_operator.operator import SparkK8sOperator
|
|
14
|
+
|
|
15
|
+
return SparkK8sOperator
|
|
16
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|