14
14
# KIND, either express or implied. See the License for the
15
15
# specific language governing permissions and limitations
16
16
# under the License.
17
- """Serve logs process ."""
17
+ """Log server written in FastAPI ."""
18
18
19
19
from __future__ import annotations
20
20
21
21
import logging
22
22
import os
23
- import socket
24
- import sys
25
- from collections import namedtuple
23
+ from typing import cast
26
24
27
- import gunicorn . app . base
28
- from flask import Flask , abort , request , send_from_directory
25
+ from fastapi import FastAPI , HTTPException , Request , status
26
+ from fastapi . staticfiles import StaticFiles
29
27
from jwt .exceptions import (
30
28
ExpiredSignatureError ,
31
29
ImmatureSignatureError ,
32
30
InvalidAudienceError ,
33
31
InvalidIssuedAtError ,
34
32
InvalidSignatureError ,
35
33
)
36
- from werkzeug .exceptions import HTTPException
37
34
38
35
from airflow .api_fastapi .auth .tokens import JWTValidator , get_signing_key
39
36
from airflow .configuration import conf
43
40
logger = logging .getLogger (__name__ )
44
41
45
42
46
- def create_app ():
47
- flask_app = Flask (__name__ , static_folder = None )
48
- leeway = conf .getint ("webserver" , "log_request_clock_grace" , fallback = 30 )
49
- log_directory = os .path .expanduser (conf .get ("logging" , "BASE_LOG_FOLDER" ))
50
- log_config_class = conf .get ("logging" , "logging_config_class" )
51
- if log_config_class :
52
- logger .info ("Detected user-defined logging config. Attempting to load %s" , log_config_class )
53
- try :
54
- logging_config = import_string (log_config_class )
55
- try :
56
- base_log_folder = logging_config ["handlers" ]["task" ]["base_log_folder" ]
57
- except KeyError :
58
- base_log_folder = None
59
- if base_log_folder is not None :
60
- log_directory = base_log_folder
61
- logger .info (
62
- "Successfully imported user-defined logging config. Flask App will serve log from %s" ,
63
- log_directory ,
64
- )
65
- else :
66
- logger .warning (
67
- "User-defined logging config does not specify 'base_log_folder'. "
68
- "Flask App will use default log directory %s" ,
69
- base_log_folder ,
70
- )
71
- except Exception as e :
72
- raise ImportError (f"Unable to load { log_config_class } due to error: { e } " )
73
- signer = JWTValidator (
74
- issuer = None ,
75
- secret_key = get_signing_key ("api" , "secret_key" ),
76
- algorithm = "HS512" ,
77
- leeway = leeway ,
78
- audience = "task-instance-logs" ,
79
- )
43
+ class JWTAuthStaticFiles (StaticFiles ):
44
+ """StaticFiles with JWT authentication."""
80
45
81
- # Prevent direct access to the logs port
82
- @flask_app .before_request
83
- def validate_pre_signed_url ():
46
+ # reference from https://github.com/fastapi/fastapi/issues/858#issuecomment-876564020
47
+
48
+ def __init__ (self , * args , ** kwargs ) -> None :
49
+ super ().__init__ (* args , ** kwargs )
50
+
51
+ async def __call__ (self , scope , receive , send ) -> None :
52
+ request = Request (scope , receive )
53
+ await self .validate_jwt_token (request )
54
+ await super ().__call__ (scope , receive , send )
55
+
56
+ async def validate_jwt_token (self , request : Request ):
57
+ # we get the signer from the app state instead of creating a new instance for each request
58
+ signer = cast ("JWTValidator" , request .app .state .signer )
84
59
try :
85
60
auth = request .headers .get ("Authorization" )
86
61
if auth is None :
87
62
logger .warning ("The Authorization header is missing: %s." , request .headers )
88
- abort (403 )
89
- payload = signer .validated_claims (auth )
63
+ raise HTTPException (
64
+ status_code = status .HTTP_403_FORBIDDEN , detail = "Authorization header missing"
65
+ )
66
+ payload = await signer .avalidated_claims (auth )
90
67
token_filename = payload .get ("filename" )
91
- request_filename = request .view_args ["filename" ]
68
+ # Extract filename from url path
69
+ request_filename = request .url .path .lstrip ("/log/" )
92
70
if token_filename is None :
93
71
logger .warning ("The payload does not contain 'filename' key: %s." , payload )
94
- abort ( 403 )
72
+ raise HTTPException ( status_code = status . HTTP_403_FORBIDDEN )
95
73
if token_filename != request_filename :
96
74
logger .warning (
97
75
"The payload log_relative_path key is different than the one in token:"
98
76
"Request path: %s. Token path: %s." ,
99
77
request_filename ,
100
78
token_filename ,
101
79
)
102
- abort ( 403 )
80
+ raise HTTPException ( status_code = status . HTTP_403_FORBIDDEN )
103
81
except HTTPException :
104
82
raise
105
83
except InvalidAudienceError :
106
84
logger .warning ("Invalid audience for the request" , exc_info = True )
107
- abort ( 403 )
85
+ raise HTTPException ( status_code = status . HTTP_403_FORBIDDEN )
108
86
except InvalidSignatureError :
109
87
logger .warning ("The signature of the request was wrong" , exc_info = True )
110
- abort ( 403 )
88
+ raise HTTPException ( status_code = status . HTTP_403_FORBIDDEN )
111
89
except ImmatureSignatureError :
112
90
logger .warning ("The signature of the request was sent from the future" , exc_info = True )
113
- abort ( 403 )
91
+ raise HTTPException ( status_code = status . HTTP_403_FORBIDDEN )
114
92
except ExpiredSignatureError :
115
93
logger .warning (
116
94
"The signature of the request has expired. Make sure that all components "
@@ -119,78 +97,64 @@ def validate_pre_signed_url():
119
97
get_docs_url ("configurations-ref.html#secret-key" ),
120
98
exc_info = True ,
121
99
)
122
- abort ( 403 )
100
+ raise HTTPException ( status_code = status . HTTP_403_FORBIDDEN )
123
101
except InvalidIssuedAtError :
124
102
logger .warning (
125
- "The request was issues in the future. Make sure that all components "
103
+ "The request was issued in the future. Make sure that all components "
126
104
"in your system have synchronized clocks. "
127
105
"See more at %s" ,
128
106
get_docs_url ("configurations-ref.html#secret-key" ),
129
107
exc_info = True ,
130
108
)
131
- abort ( 403 )
109
+ raise HTTPException ( status_code = status . HTTP_403_FORBIDDEN )
132
110
except Exception :
133
111
logger .warning ("Unknown error" , exc_info = True )
134
- abort (403 )
135
-
136
- @flask_app .route ("/log/<path:filename>" )
137
- def serve_logs_view (filename ):
138
- return send_from_directory (log_directory , filename , mimetype = "application/json" , as_attachment = False )
139
-
140
- return flask_app
141
-
142
-
143
- GunicornOption = namedtuple ("GunicornOption" , ["key" , "value" ])
144
-
112
+ raise HTTPException (status_code = status .HTTP_403_FORBIDDEN )
145
113
146
- class StandaloneGunicornApplication (gunicorn .app .base .BaseApplication ):
147
- """
148
- Standalone Gunicorn application/serve for usage with any WSGI-application.
149
114
150
- Code inspired by an example from the Gunicorn documentation.
151
- https://github.com/benoitc/gunicorn/blob/cf55d2cec277f220ebd605989ce78ad1bb553c46/examples/standalone_app.py
152
-
153
- For details, about standalone gunicorn application, see:
154
- https://docs.gunicorn.org/en/stable/custom.html
155
- """
156
-
157
- def __init__ (self , app , options = None ):
158
- self .options = options or []
159
- self .application = app
160
- super ().__init__ ()
161
-
162
- def load_config (self ):
163
- for option in self .options :
164
- self .cfg .set (option .key .lower (), option .value )
165
-
166
- def load (self ):
167
- return self .application
168
-
169
-
170
- def serve_logs (port = None ):
171
- """Serve logs generated by Worker."""
172
- # setproctitle causes issue on Mac OS: https://github.com/benoitc/gunicorn/issues/3021
173
- os_type = sys .platform
174
- if os_type == "darwin" :
175
- logger .debug ("Mac OS detected, skipping setproctitle" )
176
- else :
177
- from setproctitle import setproctitle
178
-
179
- setproctitle ("airflow serve-logs" )
180
- wsgi_app = create_app ()
115
+ def create_app ():
116
+ leeway = conf .getint ("webserver" , "log_request_clock_grace" , fallback = 30 )
117
+ log_directory = os .path .expanduser (conf .get ("logging" , "BASE_LOG_FOLDER" ))
118
+ log_config_class = conf .get ("logging" , "logging_config_class" )
119
+ if log_config_class :
120
+ logger .info ("Detected user-defined logging config. Attempting to load %s" , log_config_class )
121
+ try :
122
+ logging_config = import_string (log_config_class )
123
+ try :
124
+ base_log_folder = logging_config ["handlers" ]["task" ]["base_log_folder" ]
125
+ except KeyError :
126
+ base_log_folder = None
127
+ if base_log_folder is not None :
128
+ log_directory = base_log_folder
129
+ logger .info (
130
+ "Successfully imported user-defined logging config. FastAPI App will serve log from %s" ,
131
+ log_directory ,
132
+ )
133
+ else :
134
+ logger .warning (
135
+ "User-defined logging config does not specify 'base_log_folder'. "
136
+ "FastAPI App will use default log directory %s" ,
137
+ base_log_folder ,
138
+ )
139
+ except Exception as e :
140
+ raise ImportError (f"Unable to load { log_config_class } due to error: { e } " )
181
141
182
- port = port or conf .getint ("logging" , "WORKER_LOG_SERVER_PORT" )
142
+ fastapi_app = FastAPI ()
143
+ fastapi_app .state .signer = JWTValidator (
144
+ issuer = None ,
145
+ secret_key = get_signing_key ("api" , "secret_key" ),
146
+ algorithm = "HS512" ,
147
+ leeway = leeway ,
148
+ audience = "task-instance-logs" ,
149
+ )
183
150
184
- # If dual stack is available and IPV6_V6ONLY is not enabled on the socket
185
- # then when IPV6 is bound to it will also bind to IPV4 automatically
186
- if getattr (socket , "has_dualstack_ipv6" , lambda : False )():
187
- bind_option = GunicornOption ("bind" , f"[::]:{ port } " )
188
- else :
189
- bind_option = GunicornOption ("bind" , f"0.0.0.0:{ port } " )
151
+ fastapi_app .mount (
152
+ "/log" ,
153
+ JWTAuthStaticFiles (directory = log_directory , html = False ),
154
+ name = "serve_logs" ,
155
+ )
190
156
191
- options = [bind_option , GunicornOption ("workers" , 2 )]
192
- StandaloneGunicornApplication (wsgi_app , options ).run ()
157
+ return fastapi_app
193
158
194
159
195
- if __name__ == "__main__" :
196
- serve_logs ()
160
+ app = create_app ()
0 commit comments