wraptile 0.1.2__tar.gz → 0.2.0.dev1__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.
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/PKG-INFO +4 -4
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/pyproject.toml +4 -4
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/wraptile/exceptions.py +29 -14
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/wraptile/services/airflow/airflow_service.py +43 -45
- wraptile-0.2.0.dev1/wraptile/services/airflow/tokens.py +339 -0
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/wraptile/services/local/local_service.py +19 -3
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/.gitignore +0 -0
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/LICENSE +0 -0
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/README.md +0 -0
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/wraptile/__init__.py +0 -0
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/wraptile/app.py +0 -0
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/wraptile/cli.py +0 -0
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/wraptile/constants.py +0 -0
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/wraptile/logging.py +0 -0
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/wraptile/main.py +0 -0
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/wraptile/provider.py +0 -0
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/wraptile/py.typed +0 -0
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/wraptile/routes.py +0 -0
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/wraptile/services/__init__.py +0 -0
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/wraptile/services/airflow/__init__.py +0 -0
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/wraptile/services/base/__init__.py +0 -0
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/wraptile/services/base/service_base.py +0 -0
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/wraptile/services/local/__init__.py +0 -0
- {wraptile-0.1.2 → wraptile-0.2.0.dev1}/wraptile/services/local/testing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wraptile
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0.dev1
|
|
4
4
|
Summary: FastAPI server that implements the OGC API - Processes
|
|
5
5
|
Project-URL: Documentation, https://eo-tools.github.io/eozilla
|
|
6
6
|
Project-URL: Repository, https://github.com/eo-tools/eozilla
|
|
@@ -28,11 +28,11 @@ Classifier: Topic :: Software Development
|
|
|
28
28
|
Classifier: Typing :: Typed
|
|
29
29
|
Requires-Python: >=3.11
|
|
30
30
|
Requires-Dist: fastapi
|
|
31
|
-
Requires-Dist: gavicore>=0.
|
|
32
|
-
Requires-Dist: procodile>=0.
|
|
31
|
+
Requires-Dist: gavicore>=0.2.0.dev1
|
|
32
|
+
Requires-Dist: procodile>=0.2.0.dev1
|
|
33
33
|
Requires-Dist: pydantic
|
|
34
34
|
Requires-Dist: pyyaml
|
|
35
|
-
Requires-Dist: typer
|
|
35
|
+
Requires-Dist: typer<1,>=0.26.0
|
|
36
36
|
Requires-Dist: uvicorn
|
|
37
37
|
Description-Content-Type: text/markdown
|
|
38
38
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "wraptile"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0.dev1"
|
|
8
8
|
description = "FastAPI server that implements the OGC API - Processes"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
@@ -43,11 +43,11 @@ dependencies = [
|
|
|
43
43
|
"fastapi",
|
|
44
44
|
"pydantic",
|
|
45
45
|
"pyyaml",
|
|
46
|
-
"typer",
|
|
46
|
+
"typer >=0.26.0,<1",
|
|
47
47
|
"uvicorn",
|
|
48
48
|
# local dependency
|
|
49
|
-
"gavicore >=0.
|
|
50
|
-
"procodile >=0.
|
|
49
|
+
"gavicore >=0.2.0.dev1",
|
|
50
|
+
"procodile >=0.2.0.dev1",
|
|
51
51
|
]
|
|
52
52
|
|
|
53
53
|
[project.scripts]
|
|
@@ -2,39 +2,49 @@
|
|
|
2
2
|
# Permissions are hereby granted under the terms of the Apache 2.0 License:
|
|
3
3
|
# https://opensource.org/license/apache-2-0.
|
|
4
4
|
|
|
5
|
-
import traceback
|
|
6
5
|
from http import HTTPStatus
|
|
7
6
|
from typing import Optional
|
|
8
7
|
|
|
9
8
|
from fastapi import HTTPException
|
|
10
9
|
|
|
11
10
|
from gavicore.models import ApiError
|
|
11
|
+
from gavicore.service.errors import (
|
|
12
|
+
ErrorTypeId,
|
|
13
|
+
create_api_error,
|
|
14
|
+
get_error_type_id,
|
|
15
|
+
)
|
|
12
16
|
|
|
13
17
|
|
|
14
18
|
class ServiceException(HTTPException):
|
|
15
19
|
"""Raised if a service error occurred."""
|
|
16
20
|
|
|
21
|
+
content: ApiError
|
|
22
|
+
|
|
17
23
|
def __init__(
|
|
18
24
|
self,
|
|
19
25
|
status_code: int,
|
|
20
26
|
detail: str,
|
|
21
27
|
exception: Optional[Exception] = None,
|
|
28
|
+
type_id: ErrorTypeId | None = None,
|
|
29
|
+
is_job_problem: bool = False,
|
|
22
30
|
):
|
|
23
31
|
super().__init__(status_code=status_code, detail=detail)
|
|
24
|
-
|
|
25
|
-
|
|
32
|
+
|
|
33
|
+
if type_id is None:
|
|
34
|
+
type_id = get_error_type_id(
|
|
35
|
+
status_code,
|
|
36
|
+
is_job_problem=is_job_problem,
|
|
37
|
+
default="internal-server-error",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
title = str(exception) if exception else HTTPStatus(status_code).phrase
|
|
41
|
+
|
|
42
|
+
self.content = create_api_error(
|
|
43
|
+
type_id,
|
|
26
44
|
status=status_code,
|
|
27
|
-
title=
|
|
45
|
+
title=title,
|
|
28
46
|
detail=detail,
|
|
29
|
-
|
|
30
|
-
"x-traceback": (
|
|
31
|
-
traceback.format_exception(
|
|
32
|
-
type(exception), exception, exception.__traceback__
|
|
33
|
-
)
|
|
34
|
-
if exception is not None
|
|
35
|
-
else None
|
|
36
|
-
)
|
|
37
|
-
},
|
|
47
|
+
exception=exception,
|
|
38
48
|
)
|
|
39
49
|
|
|
40
50
|
|
|
@@ -42,4 +52,9 @@ class ServiceConfigException(ServiceException):
|
|
|
42
52
|
"""Raised if a service configuration error occurred."""
|
|
43
53
|
|
|
44
54
|
def __init__(self, message: str):
|
|
45
|
-
super().__init__(
|
|
55
|
+
super().__init__(
|
|
56
|
+
detail=message,
|
|
57
|
+
status_code=500,
|
|
58
|
+
type_id="internal-server-config-error",
|
|
59
|
+
exception=self,
|
|
60
|
+
)
|
|
@@ -5,11 +5,9 @@
|
|
|
5
5
|
import datetime
|
|
6
6
|
import logging
|
|
7
7
|
import os
|
|
8
|
-
from functools import cached_property
|
|
9
8
|
from typing import Any, Optional
|
|
10
9
|
|
|
11
10
|
import fastapi
|
|
12
|
-
import requests
|
|
13
11
|
from airflow_client.client import (
|
|
14
12
|
ApiClient,
|
|
15
13
|
ApiException,
|
|
@@ -38,6 +36,7 @@ from gavicore.models import (
|
|
|
38
36
|
)
|
|
39
37
|
from procodile.workflow import FINAL_STEP_ID
|
|
40
38
|
from wraptile.exceptions import ServiceException
|
|
39
|
+
from wraptile.services.airflow.tokens import TokenProvider, create_token_provider
|
|
41
40
|
from wraptile.services.base import ServiceBase
|
|
42
41
|
|
|
43
42
|
_log = logging.getLogger(__name__)
|
|
@@ -56,6 +55,8 @@ class AirflowService(ServiceBase):
|
|
|
56
55
|
self._airflow_base_url: Optional[str] = None
|
|
57
56
|
self._airflow_username: Optional[str] = None
|
|
58
57
|
self._airflow_password: Optional[str] = None
|
|
58
|
+
self._token_provider_instance: Optional[TokenProvider] = None
|
|
59
|
+
self._api_client: Optional[ApiClient] = None
|
|
59
60
|
|
|
60
61
|
def configure(
|
|
61
62
|
self,
|
|
@@ -172,7 +173,9 @@ class AirflowService(ServiceBase):
|
|
|
172
173
|
try:
|
|
173
174
|
dag_run = self.airflow_dag_run_api.trigger_dag_run(process_id, dag_run_body)
|
|
174
175
|
except ApiException as e:
|
|
175
|
-
raise ServiceException(
|
|
176
|
+
raise ServiceException(
|
|
177
|
+
e.status, e.reason, exception=e, is_job_problem=True
|
|
178
|
+
) from e
|
|
176
179
|
return self.dag_run_to_job_info(dag_run)
|
|
177
180
|
|
|
178
181
|
async def get_jobs(self, request: fastapi.Request, *args, **kwargs) -> JobList:
|
|
@@ -183,14 +186,18 @@ class AirflowService(ServiceBase):
|
|
|
183
186
|
owners=None, # TODO important, get for current user only
|
|
184
187
|
)
|
|
185
188
|
except ApiException as e:
|
|
186
|
-
raise ServiceException(
|
|
189
|
+
raise ServiceException(
|
|
190
|
+
e.status, detail=e.reason, exception=e, is_job_problem=True
|
|
191
|
+
) from e
|
|
187
192
|
|
|
188
193
|
jobs: list[JobInfo] = []
|
|
189
194
|
for dag in dag_collection.dags:
|
|
190
195
|
try:
|
|
191
196
|
dag_run_collection = self.airflow_dag_run_api.get_dag_runs(dag.dag_id)
|
|
192
197
|
except ApiException as e:
|
|
193
|
-
raise ServiceException(
|
|
198
|
+
raise ServiceException(
|
|
199
|
+
e.status, e.reason, exception=e, is_job_problem=True
|
|
200
|
+
) from e
|
|
194
201
|
jobs.extend(
|
|
195
202
|
self.dag_run_to_job_info(dag_run)
|
|
196
203
|
for dag_run in dag_run_collection.dag_runs
|
|
@@ -202,7 +209,9 @@ class AirflowService(ServiceBase):
|
|
|
202
209
|
try:
|
|
203
210
|
dag_run = self.airflow_dag_run_api.get_dag_run(dag_id, job_id)
|
|
204
211
|
except ApiException as e:
|
|
205
|
-
raise ServiceException(
|
|
212
|
+
raise ServiceException(
|
|
213
|
+
e.status, e.reason, exception=e, is_job_problem=True
|
|
214
|
+
) from e
|
|
206
215
|
return self.dag_run_to_job_info(dag_run)
|
|
207
216
|
|
|
208
217
|
async def dismiss_job(self, job_id: str, *args, **kwargs) -> JobInfo:
|
|
@@ -216,7 +225,9 @@ class AirflowService(ServiceBase):
|
|
|
216
225
|
dag_id, job_id, dag_run_patch
|
|
217
226
|
)
|
|
218
227
|
except ApiException as e:
|
|
219
|
-
raise ServiceException(
|
|
228
|
+
raise ServiceException(
|
|
229
|
+
e.status, e.reason, exception=e, is_job_problem=True
|
|
230
|
+
) from e
|
|
220
231
|
return self.dag_run_to_job_info(dag_run)
|
|
221
232
|
|
|
222
233
|
async def get_job_results(self, job_id: str, *args, **kwargs) -> JobResults:
|
|
@@ -269,58 +280,45 @@ class AirflowService(ServiceBase):
|
|
|
269
280
|
}
|
|
270
281
|
return mapping[dag_run_state]
|
|
271
282
|
|
|
272
|
-
|
|
283
|
+
# NOTE: these are plain properties (not cached_property) so every call
|
|
284
|
+
# re-reads airflow_client, which refreshes the bearer token on expiry. A
|
|
285
|
+
# cached_property would pin the first token for the life of the service.
|
|
286
|
+
@property
|
|
273
287
|
def airflow_dag_api(self) -> DAGApi:
|
|
274
288
|
return DAGApi(self.airflow_client)
|
|
275
289
|
|
|
276
|
-
@
|
|
290
|
+
@property
|
|
277
291
|
def airflow_dag_run_api(self) -> DAGRunApi:
|
|
278
292
|
return DAGRunApi(self.airflow_client)
|
|
279
293
|
|
|
280
|
-
@
|
|
294
|
+
@property
|
|
281
295
|
def airflow_xcom_api(self) -> XComApi:
|
|
282
296
|
return XComApi(self.airflow_client)
|
|
283
297
|
|
|
284
|
-
@
|
|
298
|
+
@property
|
|
285
299
|
def airflow_client(self) -> ApiClient:
|
|
286
300
|
airflow_base_url: str = (
|
|
287
301
|
self._airflow_base_url
|
|
288
302
|
or os.getenv("AIRFLOW_API_BASE_URL")
|
|
289
303
|
or DEFAULT_AIRFLOW_BASE_URL
|
|
290
304
|
)
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if not airflow_password:
|
|
296
|
-
raise RuntimeError(
|
|
297
|
-
"missing Airflow password; please set env var AIRFLOW_PASSWORD"
|
|
305
|
+
access_token = self._token_provider(airflow_base_url).get_token()
|
|
306
|
+
if self._api_client is None:
|
|
307
|
+
self._api_client = ApiClient(
|
|
308
|
+
Configuration(host=airflow_base_url, access_token=access_token)
|
|
298
309
|
)
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
return ApiClient(configuration)
|
|
310
|
+
else:
|
|
311
|
+
# The generated Airflow client resolves the bearer header from
|
|
312
|
+
# configuration.access_token at call time, so mutating it in place
|
|
313
|
+
# refreshes auth without discarding the connection pool.
|
|
314
|
+
self._api_client.configuration.access_token = access_token
|
|
315
|
+
return self._api_client
|
|
306
316
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
f"{base_url}/auth/token",
|
|
316
|
-
json={"username": username, "password": password},
|
|
317
|
-
timeout=10,
|
|
318
|
-
)
|
|
319
|
-
try:
|
|
320
|
-
response.raise_for_status()
|
|
321
|
-
token_data = response.json()
|
|
322
|
-
return token_data.get("access_token")
|
|
323
|
-
except requests.exceptions.HTTPError as e:
|
|
324
|
-
raise ServiceException(
|
|
325
|
-
response.status_code, detail=response.reason or str(e), exception=e
|
|
326
|
-
) from e
|
|
317
|
+
def _token_provider(self, base_url: str) -> TokenProvider:
|
|
318
|
+
if self._token_provider_instance is None:
|
|
319
|
+
self._token_provider_instance = create_token_provider(
|
|
320
|
+
base_url,
|
|
321
|
+
username=self._airflow_username,
|
|
322
|
+
password=self._airflow_password,
|
|
323
|
+
)
|
|
324
|
+
return self._token_provider_instance
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 by the Eozilla team and contributors
|
|
2
|
+
# Permissions are hereby granted under the terms of the Apache 2.0 License:
|
|
3
|
+
# https://opensource.org/license/apache-2-0.
|
|
4
|
+
|
|
5
|
+
"""Bearer tokens for the wraptile->airflow hop.
|
|
6
|
+
|
|
7
|
+
Airflow's API accepts **only tokens Airflow itself issued**. An IdP token is not
|
|
8
|
+
an Airflow bearer, even when Airflow is configured against that IdP: an auth
|
|
9
|
+
manager uses the IdP to authenticate a login and to answer authorization
|
|
10
|
+
queries, then mints its own JWT. Sending the IdP token to ``/api/v2/*`` yields
|
|
11
|
+
``403 "Invalid JWT token"``.
|
|
12
|
+
|
|
13
|
+
So the token the *gateway* wants and the token *Airflow* wants are different,
|
|
14
|
+
and both travel on the same ``Authorization`` header. The hop is therefore a
|
|
15
|
+
two-step exchange:
|
|
16
|
+
|
|
17
|
+
1. mint an IdP token via ``client_credentials`` — the gateway validates this
|
|
18
|
+
(issuer, audience, roles) on the ``/auth/token`` request;
|
|
19
|
+
2. exchange it at Airflow's ``/auth/token`` for an **Airflow** JWT, which
|
|
20
|
+
authenticates every subsequent API call.
|
|
21
|
+
|
|
22
|
+
Providers, selected by
|
|
23
|
+
[`TokenConfig.create_token_provider`][wraptile.services.airflow.tokens.TokenConfig.create_token_provider]:
|
|
24
|
+
|
|
25
|
+
* [`AirflowGatewayTokenProvider`][wraptile.services.airflow.tokens.AirflowGatewayTokenProvider] — the two-step exchange above; the
|
|
26
|
+
correct path whenever an IdP is configured.
|
|
27
|
+
* [`ClientCredentialsTokenProvider`][wraptile.services.airflow.tokens.ClientCredentialsTokenProvider] — step 1 alone. It is a *component*
|
|
28
|
+
of the exchange, not a way to talk to Airflow: its token is IdP currency.
|
|
29
|
+
* [`AirflowNativeTokenProvider`][wraptile.services.airflow.tokens.AirflowNativeTokenProvider] — Airflow's ``/auth/token`` by password,
|
|
30
|
+
for deployments with neither an IdP nor a gateway.
|
|
31
|
+
|
|
32
|
+
The environment is read in exactly one place,
|
|
33
|
+
[`TokenConfig.from_env`][wraptile.services.airflow.tokens.TokenConfig.from_env];
|
|
34
|
+
everything downstream of it takes a
|
|
35
|
+
[`TokenConfig`][wraptile.services.airflow.tokens.TokenConfig] and is testable
|
|
36
|
+
without touching ``os.environ``.
|
|
37
|
+
|
|
38
|
+
Nothing here is specific to a particular identity provider: only the standard
|
|
39
|
+
OAuth2 token endpoint and grants are used.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
import abc
|
|
43
|
+
import os
|
|
44
|
+
import time
|
|
45
|
+
from collections.abc import Mapping
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
from typing import Optional
|
|
48
|
+
|
|
49
|
+
import requests
|
|
50
|
+
|
|
51
|
+
from wraptile.exceptions import ServiceException
|
|
52
|
+
|
|
53
|
+
# Re-mint the service token this many seconds before it expires, so a request
|
|
54
|
+
# never carries an already-expired bearer (a typical provider TTL is 300s).
|
|
55
|
+
TOKEN_REFRESH_MARGIN = 30
|
|
56
|
+
|
|
57
|
+
# Used when the token response omits "expires_in".
|
|
58
|
+
DEFAULT_TOKEN_LIFETIME = 300
|
|
59
|
+
|
|
60
|
+
TOKEN_REQUEST_TIMEOUT = 10
|
|
61
|
+
|
|
62
|
+
DEFAULT_AUDIENCE = "airflow"
|
|
63
|
+
|
|
64
|
+
DEFAULT_AIRFLOW_USERNAME = "admin"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TokenProvider(abc.ABC):
|
|
68
|
+
"""Supplies the bearer token for calls to the Airflow API."""
|
|
69
|
+
|
|
70
|
+
@abc.abstractmethod
|
|
71
|
+
def get_token(self) -> str:
|
|
72
|
+
"""Return a currently valid access token."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ClientCredentialsTokenProvider(TokenProvider):
|
|
76
|
+
"""OAuth2 ``client_credentials`` grant against an OIDC provider.
|
|
77
|
+
|
|
78
|
+
The token is cached and re-minted shortly before expiry so it is not
|
|
79
|
+
re-fetched on every request.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
token_url: str,
|
|
85
|
+
client_id: str,
|
|
86
|
+
client_secret: Optional[str] = None,
|
|
87
|
+
audience: Optional[str] = None,
|
|
88
|
+
):
|
|
89
|
+
self._token_url = token_url
|
|
90
|
+
self._client_id = client_id
|
|
91
|
+
self._client_secret = client_secret
|
|
92
|
+
self._audience = audience
|
|
93
|
+
self._token: Optional[str] = None
|
|
94
|
+
self._expiry: float = 0.0
|
|
95
|
+
|
|
96
|
+
def get_token(self) -> str:
|
|
97
|
+
now = time.monotonic()
|
|
98
|
+
if self._token and now < self._expiry:
|
|
99
|
+
return self._token
|
|
100
|
+
|
|
101
|
+
data = {"grant_type": "client_credentials", "client_id": self._client_id}
|
|
102
|
+
if self._client_secret:
|
|
103
|
+
data["client_secret"] = self._client_secret
|
|
104
|
+
if self._audience:
|
|
105
|
+
# The client's audience mapper normally stamps this already;
|
|
106
|
+
# sending it explicitly is harmless and defensive.
|
|
107
|
+
data["audience"] = self._audience
|
|
108
|
+
|
|
109
|
+
response = requests.post(
|
|
110
|
+
self._token_url, data=data, timeout=TOKEN_REQUEST_TIMEOUT
|
|
111
|
+
)
|
|
112
|
+
token_data = _json_or_raise(response)
|
|
113
|
+
self._token = token_data["access_token"]
|
|
114
|
+
self._expiry = (
|
|
115
|
+
now
|
|
116
|
+
+ token_data.get("expires_in", DEFAULT_TOKEN_LIFETIME)
|
|
117
|
+
- TOKEN_REFRESH_MARGIN
|
|
118
|
+
)
|
|
119
|
+
return self._token
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class AirflowGatewayTokenProvider(TokenProvider):
|
|
123
|
+
"""Exchange an IdP service token for an Airflow-issued JWT.
|
|
124
|
+
|
|
125
|
+
Airflow's API rejects IdP tokens (``403 "Invalid JWT token"``) — it accepts
|
|
126
|
+
only JWTs it minted. Airflow's ``/auth/token`` performs the exchange, and a
|
|
127
|
+
gateway in front of it validates the IdP token on *that* request. Hence two
|
|
128
|
+
tokens, each authenticating a different leg:
|
|
129
|
+
|
|
130
|
+
* the IdP token goes on the ``Authorization`` header, satisfying the gateway;
|
|
131
|
+
* the client credentials go in the body, satisfying Airflow.
|
|
132
|
+
|
|
133
|
+
Both are needed simultaneously. Without a gateway the header is simply
|
|
134
|
+
ignored, so this provider is also correct when Airflow is reached directly.
|
|
135
|
+
|
|
136
|
+
The Airflow JWT is cached against **its own** expiry, which is unrelated to
|
|
137
|
+
the IdP token's: ``inner`` re-mints independently on its own schedule.
|
|
138
|
+
Deriving one lifetime from the other silently breaks the hop the moment the
|
|
139
|
+
shorter one lapses.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
base_url: str,
|
|
145
|
+
client_id: str,
|
|
146
|
+
client_secret: Optional[str],
|
|
147
|
+
inner: TokenProvider,
|
|
148
|
+
):
|
|
149
|
+
self._base_url = base_url
|
|
150
|
+
self._client_id = client_id
|
|
151
|
+
self._client_secret = client_secret
|
|
152
|
+
self._inner = inner
|
|
153
|
+
self._token: Optional[str] = None
|
|
154
|
+
self._expiry: float = 0.0
|
|
155
|
+
|
|
156
|
+
def get_token(self) -> str:
|
|
157
|
+
now = time.monotonic()
|
|
158
|
+
if self._token and now < self._expiry:
|
|
159
|
+
return self._token
|
|
160
|
+
|
|
161
|
+
body = {"grant_type": "client_credentials", "client_id": self._client_id}
|
|
162
|
+
if self._client_secret:
|
|
163
|
+
body["client_secret"] = self._client_secret
|
|
164
|
+
|
|
165
|
+
response = requests.post(
|
|
166
|
+
f"{self._base_url}/auth/token",
|
|
167
|
+
json=body,
|
|
168
|
+
# The IdP token authenticates this request to the gateway, not to
|
|
169
|
+
# Airflow; Airflow authenticates it from the body above.
|
|
170
|
+
headers={"Authorization": f"Bearer {self._inner.get_token()}"},
|
|
171
|
+
timeout=TOKEN_REQUEST_TIMEOUT,
|
|
172
|
+
)
|
|
173
|
+
token_data = _json_or_raise(response)
|
|
174
|
+
self._token = token_data["access_token"]
|
|
175
|
+
# Airflow's response carries no "expires_in"; its lifetime comes from
|
|
176
|
+
# the server's api_auth.jwt_expiration_time. Assume the conservative
|
|
177
|
+
# default and re-mint early — an over-eager exchange costs one request,
|
|
178
|
+
# a stale JWT costs a 403 mid-flight.
|
|
179
|
+
self._expiry = (
|
|
180
|
+
now
|
|
181
|
+
+ token_data.get("expires_in", DEFAULT_TOKEN_LIFETIME)
|
|
182
|
+
- TOKEN_REFRESH_MARGIN
|
|
183
|
+
)
|
|
184
|
+
return self._token
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class AirflowNativeTokenProvider(TokenProvider):
|
|
188
|
+
"""Airflow's own ``/auth/token`` endpoint, authenticated by password.
|
|
189
|
+
|
|
190
|
+
Only for deployments with **no IdP and no gateway**: the password grant
|
|
191
|
+
requires direct access grants enabled on the IdP client (normally, and
|
|
192
|
+
correctly, disabled), and this sends no ``Authorization`` header, so a
|
|
193
|
+
gateway enforcing JWT on ``/auth/token`` rejects it with 401 before Airflow
|
|
194
|
+
ever sees it. When an IdP is configured, use
|
|
195
|
+
[`AirflowGatewayTokenProvider`][wraptile.services.airflow.tokens.AirflowGatewayTokenProvider]
|
|
196
|
+
instead.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def __init__(self, base_url: str, username: str, password: str):
|
|
200
|
+
self._base_url = base_url
|
|
201
|
+
self._username = username
|
|
202
|
+
self._password = password
|
|
203
|
+
|
|
204
|
+
def get_token(self) -> str:
|
|
205
|
+
response = requests.post(
|
|
206
|
+
f"{self._base_url}/auth/token",
|
|
207
|
+
json={"username": self._username, "password": self._password},
|
|
208
|
+
timeout=TOKEN_REQUEST_TIMEOUT,
|
|
209
|
+
)
|
|
210
|
+
return _json_or_raise(response)["access_token"]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass(frozen=True)
|
|
214
|
+
class TokenConfig:
|
|
215
|
+
"""Everything needed to decide *how* to obtain an Airflow bearer token.
|
|
216
|
+
|
|
217
|
+
Construct it directly in tests, or via
|
|
218
|
+
[`from_env`][wraptile.services.airflow.tokens.TokenConfig.from_env] in
|
|
219
|
+
production.
|
|
220
|
+
The OIDC path wins whenever it is configured; the Airflow-native path is
|
|
221
|
+
the fallback.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
airflow_base_url: str
|
|
225
|
+
oidc_token_url: Optional[str] = None
|
|
226
|
+
oidc_client_id: Optional[str] = None
|
|
227
|
+
oidc_client_secret: Optional[str] = None
|
|
228
|
+
oidc_audience: str = DEFAULT_AUDIENCE
|
|
229
|
+
airflow_username: str = DEFAULT_AIRFLOW_USERNAME
|
|
230
|
+
airflow_password: Optional[str] = None
|
|
231
|
+
|
|
232
|
+
@classmethod
|
|
233
|
+
def from_env(
|
|
234
|
+
cls,
|
|
235
|
+
base_url: str,
|
|
236
|
+
username: Optional[str] = None,
|
|
237
|
+
password: Optional[str] = None,
|
|
238
|
+
env: Optional[Mapping[str, str]] = None,
|
|
239
|
+
# Airflow credentials passed by the caller take precedence over the
|
|
240
|
+
# environment; the OIDC settings are environment-only (deployment
|
|
241
|
+
# decides whether a gateway is in the path, not the call site).
|
|
242
|
+
) -> "TokenConfig":
|
|
243
|
+
"""Read the token settings from a mapping, `os.environ` by default.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
base_url: Base URL of the Airflow web API, used by the native
|
|
247
|
+
fallback.
|
|
248
|
+
username: Airflow username, defaults to env `AIRFLOW_USERNAME`,
|
|
249
|
+
then `admin`.
|
|
250
|
+
password: Airflow password, defaults to env `AIRFLOW_PASSWORD`.
|
|
251
|
+
env: The environment to read; injected by tests.
|
|
252
|
+
"""
|
|
253
|
+
env = os.environ if env is None else env
|
|
254
|
+
return cls(
|
|
255
|
+
airflow_base_url=base_url,
|
|
256
|
+
oidc_token_url=env.get("OIDC_TOKEN_URL") or None,
|
|
257
|
+
oidc_client_id=env.get("OIDC_CLIENT_ID") or None,
|
|
258
|
+
oidc_client_secret=env.get("OIDC_CLIENT_SECRET") or None,
|
|
259
|
+
oidc_audience=env.get("OIDC_AUDIENCE") or DEFAULT_AUDIENCE,
|
|
260
|
+
airflow_username=(
|
|
261
|
+
username or env.get("AIRFLOW_USERNAME") or DEFAULT_AIRFLOW_USERNAME
|
|
262
|
+
),
|
|
263
|
+
airflow_password=password or env.get("AIRFLOW_PASSWORD") or None,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def uses_client_credentials(self) -> bool:
|
|
268
|
+
"""Whether the OIDC client-credentials grant is configured at all."""
|
|
269
|
+
return bool(self.oidc_token_url or self.oidc_client_id)
|
|
270
|
+
|
|
271
|
+
def create_token_provider(self) -> TokenProvider:
|
|
272
|
+
"""Build the token provider this configuration selects.
|
|
273
|
+
|
|
274
|
+
With OIDC configured this returns
|
|
275
|
+
[`AirflowGatewayTokenProvider`][wraptile.services.airflow.tokens.AirflowGatewayTokenProvider],
|
|
276
|
+
never the bare
|
|
277
|
+
[`ClientCredentialsTokenProvider`][wraptile.services.airflow.tokens.ClientCredentialsTokenProvider] — the latter's
|
|
278
|
+
token is IdP currency, which Airflow rejects with
|
|
279
|
+
``403 "Invalid JWT token"``. The exchange is not an extra hop to be
|
|
280
|
+
optimised away; it is the only thing Airflow's API accepts.
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
RuntimeError: If the OIDC settings are only half-filled, or if the
|
|
284
|
+
native fallback has no Airflow password.
|
|
285
|
+
"""
|
|
286
|
+
if self.uses_client_credentials:
|
|
287
|
+
if not (self.oidc_token_url and self.oidc_client_id):
|
|
288
|
+
# Half-configuring OIDC is a deployment mistake. Falling back to
|
|
289
|
+
# the native endpoint here would paper over it and send Airflow
|
|
290
|
+
# a token the gateway was meant to validate.
|
|
291
|
+
raise RuntimeError(
|
|
292
|
+
"incomplete OIDC configuration; please set both env vars"
|
|
293
|
+
" OIDC_TOKEN_URL and OIDC_CLIENT_ID"
|
|
294
|
+
)
|
|
295
|
+
return AirflowGatewayTokenProvider(
|
|
296
|
+
self.airflow_base_url,
|
|
297
|
+
client_id=self.oidc_client_id,
|
|
298
|
+
client_secret=self.oidc_client_secret,
|
|
299
|
+
inner=ClientCredentialsTokenProvider(
|
|
300
|
+
token_url=self.oidc_token_url,
|
|
301
|
+
client_id=self.oidc_client_id,
|
|
302
|
+
client_secret=self.oidc_client_secret,
|
|
303
|
+
audience=self.oidc_audience,
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if not self.airflow_password:
|
|
308
|
+
raise RuntimeError(
|
|
309
|
+
"missing Airflow password; please set env var AIRFLOW_PASSWORD"
|
|
310
|
+
)
|
|
311
|
+
return AirflowNativeTokenProvider(
|
|
312
|
+
self.airflow_base_url,
|
|
313
|
+
username=self.airflow_username,
|
|
314
|
+
password=self.airflow_password,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def create_token_provider(
|
|
319
|
+
base_url: str,
|
|
320
|
+
username: Optional[str] = None,
|
|
321
|
+
password: Optional[str] = None,
|
|
322
|
+
) -> TokenProvider:
|
|
323
|
+
"""Select the token provider from the environment.
|
|
324
|
+
|
|
325
|
+
Convenience wrapper over ``TokenConfig.from_env(...).create_token_provider()``.
|
|
326
|
+
"""
|
|
327
|
+
return TokenConfig.from_env(
|
|
328
|
+
base_url, username=username, password=password
|
|
329
|
+
).create_token_provider()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _json_or_raise(response: requests.Response) -> dict:
|
|
333
|
+
try:
|
|
334
|
+
response.raise_for_status()
|
|
335
|
+
except requests.exceptions.HTTPError as e:
|
|
336
|
+
raise ServiceException(
|
|
337
|
+
response.status_code, detail=response.reason or str(e), exception=e
|
|
338
|
+
) from e
|
|
339
|
+
return response.json()
|
|
@@ -21,6 +21,7 @@ from gavicore.models import (
|
|
|
21
21
|
ProcessRequest,
|
|
22
22
|
ProcessSummary,
|
|
23
23
|
)
|
|
24
|
+
from gavicore.service.errors import ErrorTypeId
|
|
24
25
|
from gavicore.util.dynimp import import_value
|
|
25
26
|
from procodile import Job, Process, ProcessRegistry
|
|
26
27
|
from wraptile.exceptions import ServiceException
|
|
@@ -102,6 +103,7 @@ class LocalService(ServiceBase):
|
|
|
102
103
|
400,
|
|
103
104
|
detail=f"Invalid parameterization for process {process_id!r}: {e}",
|
|
104
105
|
exception=e,
|
|
106
|
+
type_id="bad-request",
|
|
105
107
|
) from e
|
|
106
108
|
executor = self._ensure_executor()
|
|
107
109
|
use_processes = isinstance(executor, ProcessPoolExecutor)
|
|
@@ -239,7 +241,11 @@ class LocalService(ServiceBase):
|
|
|
239
241
|
def _get_process(self, process_id: str) -> Process:
|
|
240
242
|
process = self.process_registry.get(process_id)
|
|
241
243
|
if process is None:
|
|
242
|
-
raise ServiceException(
|
|
244
|
+
raise ServiceException(
|
|
245
|
+
404,
|
|
246
|
+
detail=f"Process {process_id!r} does not exist",
|
|
247
|
+
type_id="no-such-process",
|
|
248
|
+
)
|
|
243
249
|
return process
|
|
244
250
|
|
|
245
251
|
def _get_job(
|
|
@@ -247,10 +253,20 @@ class LocalService(ServiceBase):
|
|
|
247
253
|
) -> Job:
|
|
248
254
|
job = self.jobs.get(job_id)
|
|
249
255
|
if job is None:
|
|
250
|
-
raise ServiceException(
|
|
256
|
+
raise ServiceException(
|
|
257
|
+
404, detail=f"Job {job_id!r} does not exist", type_id="no-such-job"
|
|
258
|
+
)
|
|
251
259
|
message = forbidden_status_codes.get(job.job_info.status)
|
|
260
|
+
type_id: ErrorTypeId | None = None
|
|
261
|
+
if job.job_info.status in (JobStatus.accepted, JobStatus.running):
|
|
262
|
+
type_id = "result-not-ready"
|
|
252
263
|
if message:
|
|
253
|
-
raise ServiceException(
|
|
264
|
+
raise ServiceException(
|
|
265
|
+
403,
|
|
266
|
+
detail=f"Job {job_id!r} {message}",
|
|
267
|
+
is_job_problem=True,
|
|
268
|
+
type_id=type_id,
|
|
269
|
+
)
|
|
254
270
|
return job
|
|
255
271
|
|
|
256
272
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|