scalable-pypeline 1.1.0__py2.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.
- pypeline/__init__.py +1 -0
- pypeline/celery.py +270 -0
- pypeline/celery_beat.py +254 -0
- pypeline/cli/__init__.py +0 -0
- pypeline/cli/config_server.py +48 -0
- pypeline/cli/core.py +32 -0
- pypeline/cli/deploy.py +138 -0
- pypeline/cloud.py +80 -0
- pypeline/constants.py +139 -0
- pypeline/deploy.py +167 -0
- pypeline/extensions.py +16 -0
- pypeline/flask/__init__.py +28 -0
- pypeline/flask/api/__init__.py +0 -0
- pypeline/flask/api/pipelines.py +245 -0
- pypeline/flask/api/schedules.py +67 -0
- pypeline/flask/api/utils.py +36 -0
- pypeline/flask/decorators.py +92 -0
- pypeline/flask/flask_sermos.py +219 -0
- pypeline/generators.py +196 -0
- pypeline/lib/__init__.py +0 -0
- pypeline/lib/config_server.py +159 -0
- pypeline/logging_config.py +171 -0
- pypeline/pipeline_config_schema.py +197 -0
- pypeline/schedule_config_schema.py +210 -0
- pypeline/sermos_yaml.py +737 -0
- pypeline/utils/__init__.py +0 -0
- pypeline/utils/config_utils.py +327 -0
- pypeline/utils/graph_utils.py +144 -0
- pypeline/utils/module_utils.py +119 -0
- pypeline/utils/task_utils.py +803 -0
- scalable_pypeline-1.1.0.dist-info/LICENSE +177 -0
- scalable_pypeline-1.1.0.dist-info/METADATA +166 -0
- scalable_pypeline-1.1.0.dist-info/RECORD +38 -0
- scalable_pypeline-1.1.0.dist-info/WHEEL +6 -0
- scalable_pypeline-1.1.0.dist-info/entry_points.txt +2 -0
- scalable_pypeline-1.1.0.dist-info/top_level.txt +2 -0
- tests/fixtures/__init__.py +1 -0
- tests/fixtures/s3_fixtures.py +52 -0
@@ -0,0 +1,245 @@
|
|
1
|
+
""" Pipeline APIs
|
2
|
+
"""
|
3
|
+
import logging
|
4
|
+
|
5
|
+
from celery.canvas import _chain
|
6
|
+
from celery_dyrygent.workflows import Workflow
|
7
|
+
from flask import jsonify, request
|
8
|
+
from flask.views import MethodView
|
9
|
+
from rho_web.smorest import Blueprint
|
10
|
+
from rho_web.response import abort
|
11
|
+
from marshmallow import Schema, fields
|
12
|
+
from marshmallow.exceptions import ValidationError
|
13
|
+
from pypeline.constants import API_DOC_RESPONSES, API_DOC_PARAMS, API_PATH_V1
|
14
|
+
from pypeline.flask.decorators import require_accesskey
|
15
|
+
from pypeline.flask.api.utils import chain_helper
|
16
|
+
from pypeline.utils.task_utils import PipelineResult
|
17
|
+
from pypeline.utils.config_utils import retrieve_latest_pipeline_config
|
18
|
+
from pypeline.pipeline_config_schema import BasePipelineSchema, PipelineSchemaV1
|
19
|
+
|
20
|
+
logger = logging.getLogger(__name__)
|
21
|
+
bp = Blueprint('pipelines', __name__, url_prefix=API_PATH_V1 + '/pipelines')
|
22
|
+
|
23
|
+
|
24
|
+
class InvokePipelineSchema(Schema):
|
25
|
+
""" Incoming schema for invoking a pipeline
|
26
|
+
"""
|
27
|
+
chain_payload = fields.Raw(
|
28
|
+
description='Payload contains whatever arguments the pipeline expects '
|
29
|
+
'to be passed to each node in the graph.',
|
30
|
+
example={
|
31
|
+
'document_id': '123',
|
32
|
+
'send_alert': True
|
33
|
+
},
|
34
|
+
required=False)
|
35
|
+
|
36
|
+
|
37
|
+
class InvokePipelineResponseSchema(Schema):
|
38
|
+
execution_id = fields.String()
|
39
|
+
pipeline_id = fields.String()
|
40
|
+
status = fields.String()
|
41
|
+
|
42
|
+
|
43
|
+
class GetPipelineResultResponseSchema(Schema):
|
44
|
+
execution_id = fields.String()
|
45
|
+
result = fields.Raw()
|
46
|
+
result_ttl = fields.Integer()
|
47
|
+
results = fields.Raw()
|
48
|
+
status = fields.String()
|
49
|
+
status_message = fields.String()
|
50
|
+
|
51
|
+
|
52
|
+
@bp.route('/')
|
53
|
+
class Pipelines(MethodView):
|
54
|
+
""" Operations against all pipelines.
|
55
|
+
"""
|
56
|
+
@require_accesskey
|
57
|
+
@bp.doc(responses=API_DOC_RESPONSES,
|
58
|
+
parameters=[API_DOC_PARAMS['accesskey']],
|
59
|
+
tags=['Pipelines'])
|
60
|
+
def get(self):
|
61
|
+
""" Retrieve list of available pipelines.
|
62
|
+
"""
|
63
|
+
access_key = request.headers.get('accesskey')
|
64
|
+
pipeline_config_api_resp = retrieve_latest_pipeline_config(
|
65
|
+
access_key=access_key)
|
66
|
+
|
67
|
+
if pipeline_config_api_resp is None:
|
68
|
+
abort(404)
|
69
|
+
|
70
|
+
try:
|
71
|
+
pipelines = []
|
72
|
+
for p in pipeline_config_api_resp:
|
73
|
+
PipelineSchema = \
|
74
|
+
BasePipelineSchema.get_by_version(p['schemaVersion'])
|
75
|
+
pipeline_config = PipelineSchema().load(p)
|
76
|
+
pipelines.append(pipeline_config)
|
77
|
+
except ValidationError as e:
|
78
|
+
msg = f"Invalid pipeline configuration: {e}"
|
79
|
+
return jsonify({'message': msg}), 202
|
80
|
+
|
81
|
+
return jsonify(pipelines)
|
82
|
+
|
83
|
+
|
84
|
+
@bp.route('/<string:pipeline_id>')
|
85
|
+
class PipelineInfo(MethodView):
|
86
|
+
""" Operations against a single pipeline
|
87
|
+
"""
|
88
|
+
@require_accesskey
|
89
|
+
@bp.doc(responses=API_DOC_RESPONSES,
|
90
|
+
parameters=[
|
91
|
+
API_DOC_PARAMS['accesskey'], {
|
92
|
+
'in': 'path',
|
93
|
+
'name': 'pipeline_id',
|
94
|
+
'description':
|
95
|
+
'pipeline_id for which to retrieve metrics.',
|
96
|
+
'type': 'string',
|
97
|
+
'example': 'my_pipeline',
|
98
|
+
'required': True
|
99
|
+
}
|
100
|
+
],
|
101
|
+
tags=['Pipelines'])
|
102
|
+
def get(self, pipeline_id: str):
|
103
|
+
""" Retrieve details about a specific pipeline.
|
104
|
+
"""
|
105
|
+
access_key = request.headers.get('accesskey')
|
106
|
+
pipeline_config_api_resp = retrieve_latest_pipeline_config(
|
107
|
+
pipeline_id=pipeline_id, access_key=access_key)
|
108
|
+
|
109
|
+
if pipeline_config_api_resp is None:
|
110
|
+
abort(404)
|
111
|
+
|
112
|
+
try:
|
113
|
+
pipeline_config = PipelineSchemaV1().load(pipeline_config_api_resp)
|
114
|
+
except ValidationError as e:
|
115
|
+
msg = f"Invalid pipeline configuration: {e}"
|
116
|
+
return jsonify({'message': msg}), 202
|
117
|
+
|
118
|
+
return jsonify(pipeline_config)
|
119
|
+
|
120
|
+
|
121
|
+
@bp.route('/invoke/<string:pipeline_id>')
|
122
|
+
class PipelineInvoke(MethodView):
|
123
|
+
""" Operations involed with pipeline invocation
|
124
|
+
"""
|
125
|
+
@require_accesskey
|
126
|
+
@bp.doc(responses=API_DOC_RESPONSES,
|
127
|
+
parameters=[
|
128
|
+
API_DOC_PARAMS['accesskey'], {
|
129
|
+
'in': 'path',
|
130
|
+
'name': 'pipeline_id',
|
131
|
+
'description':
|
132
|
+
'pipeline_id for which to retrieve metrics.',
|
133
|
+
'type': 'string',
|
134
|
+
'example': 'my_pipeline',
|
135
|
+
'required': True
|
136
|
+
}
|
137
|
+
],
|
138
|
+
tags=['Pipelines'])
|
139
|
+
@bp.arguments(InvokePipelineSchema)
|
140
|
+
@bp.response(InvokePipelineResponseSchema)
|
141
|
+
def post(self, payload: dict, pipeline_id: str):
|
142
|
+
""" Invoke a pipeline by it's ID; optionally provide pipeline arguments.
|
143
|
+
"""
|
144
|
+
|
145
|
+
access_key = request.headers.get('accesskey')
|
146
|
+
pipeline_config = retrieve_latest_pipeline_config(
|
147
|
+
pipeline_id=pipeline_id, access_key=access_key)
|
148
|
+
|
149
|
+
if pipeline_config is None:
|
150
|
+
return abort(404)
|
151
|
+
|
152
|
+
retval = {'pipeline_id': pipeline_id, 'status': ''}
|
153
|
+
try:
|
154
|
+
# TODO - ideally we can validate the payload *at this stage*
|
155
|
+
# before the chain is ever invoked so we can handle issues
|
156
|
+
# without kicking off work.
|
157
|
+
payload = payload['chain_payload']\
|
158
|
+
if 'chain_payload' in payload else {}
|
159
|
+
|
160
|
+
gen = chain_helper(pipeline_id=pipeline_id,
|
161
|
+
access_key=access_key,
|
162
|
+
chain_payload=payload)
|
163
|
+
|
164
|
+
if gen.chain is None:
|
165
|
+
abort(400, message=gen.loading_message)
|
166
|
+
|
167
|
+
chain: _chain = gen.chain
|
168
|
+
wf: Workflow = Workflow()
|
169
|
+
wf.add_celery_canvas(chain)
|
170
|
+
wf.apply_async()
|
171
|
+
retval['status'] = 'success'
|
172
|
+
retval['execution_id'] = gen.execution_id
|
173
|
+
# Initialize the cached result
|
174
|
+
pr = PipelineResult(gen.execution_id, status='pending')
|
175
|
+
pr.save()
|
176
|
+
|
177
|
+
except Exception as e:
|
178
|
+
msg = "Failed to invoke pipeline ... {}".format(pipeline_id)
|
179
|
+
logger.error(msg)
|
180
|
+
logger.exception(f"{e}")
|
181
|
+
abort(500, message=msg)
|
182
|
+
|
183
|
+
return jsonify(retval)
|
184
|
+
|
185
|
+
|
186
|
+
results_responses = API_DOC_RESPONSES.copy()
|
187
|
+
results_responses[202] = {
|
188
|
+
'code': 202,
|
189
|
+
'description': 'Pipeline is still running. Try again later.'
|
190
|
+
}
|
191
|
+
results_responses[204] = {
|
192
|
+
'code': 204,
|
193
|
+
'description': 'The execution results have expired. Re-run pipeline.'
|
194
|
+
}
|
195
|
+
|
196
|
+
|
197
|
+
@bp.route('/results/<string:execution_id>')
|
198
|
+
class PipelineResults(MethodView):
|
199
|
+
""" Operations with respect to pipeline results
|
200
|
+
"""
|
201
|
+
@require_accesskey
|
202
|
+
@bp.doc(responses=results_responses,
|
203
|
+
parameters=[
|
204
|
+
API_DOC_PARAMS['accesskey'], {
|
205
|
+
'in': 'path',
|
206
|
+
'name': 'execution_id',
|
207
|
+
'description':
|
208
|
+
'execution_id for which to retrieve results',
|
209
|
+
'type': 'string',
|
210
|
+
'example': '4c595cca-9bf1-4150-8c34-6b43faf276c8',
|
211
|
+
'required': True
|
212
|
+
}
|
213
|
+
],
|
214
|
+
tags=['Pipelines'])
|
215
|
+
@bp.response(GetPipelineResultResponseSchema)
|
216
|
+
def get(self, execution_id: str):
|
217
|
+
""" Retrieve results of a pipeline's execution based on execution_id
|
218
|
+
|
219
|
+
NOTE: Cached results expire after a time window so are not available
|
220
|
+
forever.
|
221
|
+
|
222
|
+
TODO: Need to add response marshalling/schema here.
|
223
|
+
"""
|
224
|
+
try:
|
225
|
+
pr = PipelineResult(execution_id)
|
226
|
+
pr.load()
|
227
|
+
retval = pr.to_dict()
|
228
|
+
if pr.status == 'unavailable':
|
229
|
+
retval['status_message'] = 'Results expired. Re-run pipeline.'
|
230
|
+
return retval, 204
|
231
|
+
|
232
|
+
if pr.status == 'pending':
|
233
|
+
retval['status_message'] = 'Results pending. Check again soon.'
|
234
|
+
return retval, 202
|
235
|
+
|
236
|
+
else:
|
237
|
+
retval['status_message'] = 'Results available.'
|
238
|
+
return retval, 200
|
239
|
+
|
240
|
+
except Exception as e:
|
241
|
+
msg = "Failed to retrieve results for execution id: {}".format(
|
242
|
+
execution_id)
|
243
|
+
logger.error(msg)
|
244
|
+
logger.exception(f"{e}")
|
245
|
+
abort(500, message=msg)
|
@@ -0,0 +1,67 @@
|
|
1
|
+
""" API Endpoints for Scheduled Tasks
|
2
|
+
"""
|
3
|
+
import os
|
4
|
+
import logging
|
5
|
+
from flask import jsonify, request
|
6
|
+
from flask.views import MethodView
|
7
|
+
from marshmallow import Schema, fields
|
8
|
+
from rho_web.smorest import Blueprint
|
9
|
+
from rho_web.response import abort
|
10
|
+
from marshmallow.exceptions import ValidationError
|
11
|
+
from pypeline.constants import API_DOC_RESPONSES, API_DOC_PARAMS,\
|
12
|
+
API_PATH_V1
|
13
|
+
from pypeline.utils.config_utils import retrieve_latest_schedule_config, \
|
14
|
+
update_schedule_config
|
15
|
+
from pypeline.schedule_config_schema import BaseScheduleSchema
|
16
|
+
from pypeline.flask.decorators import require_accesskey
|
17
|
+
|
18
|
+
logger = logging.getLogger(__name__)
|
19
|
+
|
20
|
+
bp = Blueprint('schedules', __name__, url_prefix=API_PATH_V1 + '/schedules')
|
21
|
+
|
22
|
+
|
23
|
+
@bp.route('/')
|
24
|
+
class Schedules(MethodView):
|
25
|
+
""" Operations related to schedules
|
26
|
+
"""
|
27
|
+
@require_accesskey
|
28
|
+
@bp.doc(responses=API_DOC_RESPONSES,
|
29
|
+
parameters=[API_DOC_PARAMS['accesskey']],
|
30
|
+
tags=['Schedules'])
|
31
|
+
def get(self):
|
32
|
+
""" Retrieve list of available schedule entries.
|
33
|
+
"""
|
34
|
+
access_key = request.headers.get('accesskey')
|
35
|
+
try:
|
36
|
+
schedule_config = retrieve_latest_schedule_config(
|
37
|
+
access_key=access_key)
|
38
|
+
except ValidationError:
|
39
|
+
abort(400, message="Invalid schedule found ...")
|
40
|
+
|
41
|
+
if schedule_config is None:
|
42
|
+
abort(404)
|
43
|
+
|
44
|
+
return jsonify(schedule_config)
|
45
|
+
|
46
|
+
@require_accesskey
|
47
|
+
@bp.doc(responses=API_DOC_RESPONSES,
|
48
|
+
parameters=[API_DOC_PARAMS['accesskey']],
|
49
|
+
tags=['Schedules'])
|
50
|
+
@bp.arguments(BaseScheduleSchema)
|
51
|
+
def post(self, payload: dict):
|
52
|
+
""" Update a deployment's schedules. Primarily used to update dynamic
|
53
|
+
keys such as last run at and total run count. This does not allow
|
54
|
+
overloading schedules, only updating select keys on known schedule
|
55
|
+
entries (as in, this is not destructive).
|
56
|
+
"""
|
57
|
+
access_key = request.headers.get('accesskey')
|
58
|
+
try:
|
59
|
+
success = update_schedule_config(new_schedule_config=payload,
|
60
|
+
access_key=access_key)
|
61
|
+
except ValidationError as e:
|
62
|
+
abort(400, message=e)
|
63
|
+
|
64
|
+
if not success:
|
65
|
+
abort(500)
|
66
|
+
|
67
|
+
return jsonify({'message': 'Schedule update successful ...'})
|
@@ -0,0 +1,36 @@
|
|
1
|
+
""" Utilities for Sermos APIs and interacting with Pipelines/Schedules
|
2
|
+
"""
|
3
|
+
import logging
|
4
|
+
from typing import Union
|
5
|
+
from pypeline.utils.task_utils import PipelineGenerator
|
6
|
+
|
7
|
+
logger = logging.getLogger(__name__)
|
8
|
+
|
9
|
+
|
10
|
+
def chain_helper(pipeline_id: str,
|
11
|
+
access_key: Union[str, None] = None,
|
12
|
+
chain_payload: Union[dict, None] = None,
|
13
|
+
add_retry: bool = True,
|
14
|
+
queue: Union[str, None] = None,
|
15
|
+
default_task_ttl: int = None):
|
16
|
+
""" Helper method to generate a pipeline chain *with* error handling.
|
17
|
+
|
18
|
+
Usage:
|
19
|
+
my_chain = chain_helper('pipeline-name')
|
20
|
+
my_chain.delay()
|
21
|
+
"""
|
22
|
+
# Get our pipeline. The PipelineGenerator will use the PipelineRunWrapper
|
23
|
+
# to cache this "run" of the pipeline.
|
24
|
+
gen = PipelineGenerator(pipeline_id,
|
25
|
+
access_key=access_key,
|
26
|
+
queue=queue,
|
27
|
+
default_task_ttl=default_task_ttl,
|
28
|
+
add_retry=add_retry,
|
29
|
+
chain_payload=chain_payload)
|
30
|
+
if gen.good_to_go:
|
31
|
+
# Generate our 'chain', which is the grouping of celery constructs that
|
32
|
+
# allows our dag to run asynchronously and synchronously according to
|
33
|
+
# the adjacency list defined in our pipeline configuration.
|
34
|
+
gen.generate_chain()
|
35
|
+
|
36
|
+
return gen
|
@@ -0,0 +1,92 @@
|
|
1
|
+
""" Flask specific decorators (primarily for auth activities and app context)
|
2
|
+
"""
|
3
|
+
import os
|
4
|
+
import logging
|
5
|
+
from http import HTTPStatus
|
6
|
+
from functools import wraps
|
7
|
+
import requests
|
8
|
+
from rhodb.redis_conf import RedisConnector
|
9
|
+
from flask import current_app, request
|
10
|
+
from rho_web.response import abort
|
11
|
+
from pypeline.flask import oidc
|
12
|
+
from pypeline.constants import DEFAULT_AUTH_URL, AUTH_LOCK_KEY, \
|
13
|
+
AUTH_LOCK_DURATION, USING_SERMOS_CLOUD
|
14
|
+
|
15
|
+
logger = logging.getLogger(__name__)
|
16
|
+
redis_conn = RedisConnector().get_connection()
|
17
|
+
|
18
|
+
|
19
|
+
def require_login(fn):
|
20
|
+
""" Utilize Flask RhoAuth to secure a route with @require_login decorator
|
21
|
+
"""
|
22
|
+
secure_fn = oidc.require_login(fn)
|
23
|
+
|
24
|
+
@wraps(fn)
|
25
|
+
def decorated(*args, **kwargs):
|
26
|
+
if str(current_app.config['RHOAUTH_ENABLED']).lower() == 'true':
|
27
|
+
return secure_fn(*args, **kwargs)
|
28
|
+
return fn(*args, **kwargs)
|
29
|
+
|
30
|
+
return decorated
|
31
|
+
|
32
|
+
|
33
|
+
def validate_access_key(access_key: str = None):
|
34
|
+
""" Verify whether an Access Key is valid according to Sermos Cloud.
|
35
|
+
|
36
|
+
If deploying in 'local' mode, no validation is done. To deploy in local
|
37
|
+
mode, set DEFAULT_BASE_URL=local in your environment.
|
38
|
+
"""
|
39
|
+
# Always 'valid' in local mode
|
40
|
+
if not USING_SERMOS_CLOUD:
|
41
|
+
return True
|
42
|
+
|
43
|
+
# If get access key from either provided val or environment
|
44
|
+
# if None provided.
|
45
|
+
access_key = os.environ.get('SERMOS_ACCESS_KEY', access_key)
|
46
|
+
|
47
|
+
# Invalid if None, no need to ask.
|
48
|
+
if access_key is None:
|
49
|
+
return False
|
50
|
+
|
51
|
+
# Ask cache first
|
52
|
+
# TODO update to remove Redis as a dependency, merely an optional feature.
|
53
|
+
validated = redis_conn.get(AUTH_LOCK_KEY)
|
54
|
+
if validated is not None:
|
55
|
+
return True
|
56
|
+
|
57
|
+
# Ask Sermos Cloud (Note: Sermos Cloud's API expects `apikey`)
|
58
|
+
headers = {'apikey': access_key}
|
59
|
+
r = requests.post(DEFAULT_AUTH_URL, headers=headers, verify=True)
|
60
|
+
|
61
|
+
if r.status_code == 200:
|
62
|
+
redis_conn.setex(AUTH_LOCK_KEY, AUTH_LOCK_DURATION, '')
|
63
|
+
return True
|
64
|
+
return False
|
65
|
+
|
66
|
+
|
67
|
+
def require_accesskey(fn):
|
68
|
+
""" Convenience decorator to add to a web route (typically an API)
|
69
|
+
when using Flask.
|
70
|
+
|
71
|
+
Usage::
|
72
|
+
from sermos import Blueprint, ApiServices
|
73
|
+
bp = Blueprint('api_routes', __name__, url_prefix='/api')
|
74
|
+
|
75
|
+
@bp.route('/my-api-route')
|
76
|
+
class ApiClass(MethodView):
|
77
|
+
@require_access_key
|
78
|
+
def post(self, payload: dict):
|
79
|
+
return {}
|
80
|
+
"""
|
81
|
+
@wraps(fn)
|
82
|
+
def decorated_view(*args, **kwargs):
|
83
|
+
access_key = request.headers.get('accesskey')
|
84
|
+
if not access_key:
|
85
|
+
access_key = request.args.get('accesskey')
|
86
|
+
|
87
|
+
if validate_access_key(access_key):
|
88
|
+
return fn(*args, **kwargs)
|
89
|
+
|
90
|
+
abort(HTTPStatus.UNAUTHORIZED)
|
91
|
+
|
92
|
+
return decorated_view
|
@@ -0,0 +1,219 @@
|
|
1
|
+
""" Sermos implementation as a Flask extension
|
2
|
+
"""
|
3
|
+
import os
|
4
|
+
if os.getenv('USE_GEVENT', 'false').lower() == 'true':
|
5
|
+
import gevent.monkey
|
6
|
+
gevent.monkey.patch_all()
|
7
|
+
|
8
|
+
import logging
|
9
|
+
from functools import partial
|
10
|
+
from flask import Flask
|
11
|
+
from werkzeug.middleware.proxy_fix import ProxyFix
|
12
|
+
from rho_web.smorest import Api, Blueprint
|
13
|
+
from pypeline.utils.task_utils import TaskRunner, PipelineResult
|
14
|
+
from pypeline.logging_config import setup_logging
|
15
|
+
from pypeline.extensions import sermos_config
|
16
|
+
from pypeline.flask import oidc
|
17
|
+
from pypeline.constants import DEFAULT_OPENAPI_CONFIG, DEFAULT_RHOAUTH_CONFIG
|
18
|
+
from pypeline import __version__
|
19
|
+
|
20
|
+
logger = logging.getLogger(__name__)
|
21
|
+
|
22
|
+
|
23
|
+
class FlaskSermos:
|
24
|
+
""" Sermos Flask extension.
|
25
|
+
"""
|
26
|
+
def __init__(self, app: Flask = None):
|
27
|
+
""" Class init
|
28
|
+
"""
|
29
|
+
self.app = app
|
30
|
+
self.sermos_config = sermos_config if sermos_config is not None else {}
|
31
|
+
|
32
|
+
if app is not None:
|
33
|
+
self.init_app(app)
|
34
|
+
|
35
|
+
def init_app(self, app: Flask, init_api: bool = False):
|
36
|
+
""" Sermos bootstrapping process.
|
37
|
+
|
38
|
+
Application config variables to set include:
|
39
|
+
|
40
|
+
SERMOS_CLIENT_VERSION (default: v?.?.?)
|
41
|
+
RHOAUTH_ENABLED (default: True)
|
42
|
+
SERMOS_HIJACK_ROOT_LOGGER (default: False)
|
43
|
+
|
44
|
+
Optional, if `init_api` is True:
|
45
|
+
|
46
|
+
API_DOCUMENTATION_TITLE
|
47
|
+
API_DOCUMENTATION_DESCRIPTION
|
48
|
+
OPENAPI_VERSION
|
49
|
+
OPENAPI_URL_PREFIX
|
50
|
+
OPENAPI_SWAGGER_APP_NAME
|
51
|
+
OPENAPI_SWAGGER_UI_PATH
|
52
|
+
OPENAPI_SWAGGER_BASE_TEMPLATE
|
53
|
+
OPENAPI_SWAGGER_URL
|
54
|
+
OPENAPI_SWAGGER_UI_URL
|
55
|
+
SWAGGER_UI_DOC_EXPANSION
|
56
|
+
EXPLAIN_TEMPLATE_LOADING
|
57
|
+
|
58
|
+
Args:
|
59
|
+
app (Flask): Flask Application to initialize.
|
60
|
+
init_api (bool): If `True`, Sermos will initialize its
|
61
|
+
core APIs (including Pipelines, Scheduled Tasks, etc.) and
|
62
|
+
provide a pre-configured OpenAPI Spec/Swagger UI interface
|
63
|
+
available at the route defined in your application's config
|
64
|
+
under `OPENAPI_URL_PREFIX` (default `/api`). Refer to
|
65
|
+
[flask-smorest](https://flask-smorest.readthedocs.io/en/latest/openapi.html)
|
66
|
+
documentation for additional configuration options.
|
67
|
+
"""
|
68
|
+
# Ensure there's a SERMOS_CLIENT_VERSION on app config
|
69
|
+
app.config.setdefault(
|
70
|
+
'SERMOS_CLIENT_VERSION',
|
71
|
+
app.config.get("SERMOS_CLIENT_VERSION", "v?.?.?"))
|
72
|
+
|
73
|
+
# Bootstrap logging if app requests
|
74
|
+
self._bootstrap_logging(app)
|
75
|
+
|
76
|
+
app.wsgi_app = ProxyFix(app.wsgi_app)
|
77
|
+
app.url_map.strict_slashes = False
|
78
|
+
|
79
|
+
# Create and register the sermos blueprint
|
80
|
+
bp = Blueprint('sermos',
|
81
|
+
__name__,
|
82
|
+
template_folder='../templates',
|
83
|
+
static_folder='../static',
|
84
|
+
url_prefix='/sermos')
|
85
|
+
app.register_blueprint(bp)
|
86
|
+
|
87
|
+
# Bootstrap RhoAuth if app requests
|
88
|
+
self._bootstrap_rhoauth(app)
|
89
|
+
|
90
|
+
# Bootstrap api if app requests
|
91
|
+
if init_api is True:
|
92
|
+
self._bootstrap_api(app)
|
93
|
+
|
94
|
+
def _bootstrap_rhoauth(self, app: Flask):
|
95
|
+
""" If RHOAUTH_ENABLED is True, bootstrap the application with default
|
96
|
+
configuration, precedent given to the provide app.
|
97
|
+
"""
|
98
|
+
# TODO Remove rhoauth by default and/or entirely in future release.
|
99
|
+
rhoauth_enabled = str(app.config.get('RHOAUTH_ENABLED',
|
100
|
+
True)).lower() == 'true'
|
101
|
+
app.config.setdefault('RHOAUTH_ENABLED', rhoauth_enabled)
|
102
|
+
if rhoauth_enabled is True:
|
103
|
+
# Set useful defaults so users don't need to request.
|
104
|
+
for rhoauth_config in DEFAULT_RHOAUTH_CONFIG:
|
105
|
+
app.config.setdefault(
|
106
|
+
rhoauth_config[0],
|
107
|
+
app.config.get(rhoauth_config[0], rhoauth_config[1]))
|
108
|
+
|
109
|
+
# Initialize open id connect for authentication
|
110
|
+
if rhoauth_enabled is True:
|
111
|
+
logger.info("Initializing using RhoAuth ...")
|
112
|
+
self.oidc = oidc
|
113
|
+
self.oidc.init_app(app)
|
114
|
+
|
115
|
+
@app.route('/logout')
|
116
|
+
def logout():
|
117
|
+
return self.oidc.logout("/")
|
118
|
+
|
119
|
+
@staticmethod
|
120
|
+
def _bootstrap_logging(app: Flask):
|
121
|
+
""" If application requests Sermos logging, enable here
|
122
|
+
|
123
|
+
Main reason to do this is to have clear versioning in each log
|
124
|
+
and to supress the elasticsearch log level by default, which is
|
125
|
+
extremely verbose if using elasticsearch-py. There is no dependency
|
126
|
+
on elasticsearch - we simply create the logger and upgrade level
|
127
|
+
because it's extremely annoying if you do need to use ES.
|
128
|
+
"""
|
129
|
+
if app.config.get('SERMOS_HIJACK_ROOT_LOGGER', False):
|
130
|
+
overload_es = app.config.get('OVERLOAD_ES_LOGGING', True)
|
131
|
+
logger.info("Initializing using Sermos logging ...")
|
132
|
+
setup_logging(app_version=__version__,
|
133
|
+
client_version=app.config['SERMOS_CLIENT_VERSION'],
|
134
|
+
default_level=app.config.get("LOG_LEVEL", "INFO"),
|
135
|
+
overload_elasticsearch=overload_es,
|
136
|
+
establish_logging_config=True)
|
137
|
+
|
138
|
+
def _bootstrap_api(self, app: Flask):
|
139
|
+
""" If initializing the API, we will create the core Sermos API paths
|
140
|
+
and initialize the default Swagger documentation.
|
141
|
+
"""
|
142
|
+
# Set sensible defaults for Swagger docs. Provided `app` will
|
143
|
+
# take precedent.
|
144
|
+
for swagger_config in DEFAULT_OPENAPI_CONFIG:
|
145
|
+
app.config.setdefault(
|
146
|
+
swagger_config[0],
|
147
|
+
app.config.get(swagger_config[0], swagger_config[1]))
|
148
|
+
|
149
|
+
# Attempt to override with values from client's sermos.yaml if
|
150
|
+
# they are available. This will add new tags and new docs if
|
151
|
+
# defined and add to the core Sermos API docs.
|
152
|
+
api_config = self.sermos_config.get('apiConfig', {})
|
153
|
+
api_docs = api_config.get('apiDocumentation', {})
|
154
|
+
|
155
|
+
custom_tags = api_config.get('prefixDescriptions', [])
|
156
|
+
|
157
|
+
app.config['SERMOS_CLIENT_VERSION'] = \
|
158
|
+
api_docs.get('version', None) \
|
159
|
+
if api_docs.get('version', None) is not None \
|
160
|
+
else app.config['SERMOS_CLIENT_VERSION']
|
161
|
+
|
162
|
+
app.config['API_DOCUMENTATION_TITLE'] = \
|
163
|
+
api_docs.get('title', None) \
|
164
|
+
if api_docs.get('title', None) is not None \
|
165
|
+
else app.config['API_DOCUMENTATION_TITLE']
|
166
|
+
|
167
|
+
app.config['API_DOCUMENTATION_DESCRIPTION'] = \
|
168
|
+
api_docs.get('description', None) \
|
169
|
+
if api_docs.get('description', None) is not None \
|
170
|
+
else app.config['API_DOCUMENTATION_DESCRIPTION']
|
171
|
+
|
172
|
+
# Set default Sermos Tags along with custom tags from sermos.yaml
|
173
|
+
tags = [{
|
174
|
+
'name': 'Pipelines',
|
175
|
+
'description': 'Operations related to Pipelines'
|
176
|
+
}, {
|
177
|
+
'name': 'Schedules',
|
178
|
+
'description': 'Operations related to Schedules'
|
179
|
+
}] + custom_tags
|
180
|
+
|
181
|
+
# Set up the initializing spec kwargs for API
|
182
|
+
spec_kwargs = {
|
183
|
+
'title': app.config['API_DOCUMENTATION_TITLE'],
|
184
|
+
'version': f"Sermos: {__version__} - "
|
185
|
+
f"Client: {app.config['SERMOS_CLIENT_VERSION']}",
|
186
|
+
'description': app.config['API_DOCUMENTATION_DESCRIPTION'],
|
187
|
+
'tags': tags
|
188
|
+
}
|
189
|
+
try:
|
190
|
+
# Initialize the API documentation and ensure RhoAuth validates
|
191
|
+
# correct `role`
|
192
|
+
if str(app.config['RHOAUTH_ENABLED']).lower() == 'true':
|
193
|
+
decorator = partial(self.oidc.require_login,
|
194
|
+
roles=['sermos-client-api-docs'])
|
195
|
+
api = Api(auth_decorator=decorator)
|
196
|
+
else:
|
197
|
+
api = Api()
|
198
|
+
|
199
|
+
api.init_app(app, spec_kwargs=spec_kwargs)
|
200
|
+
|
201
|
+
# Register available Sermos API Namespaces
|
202
|
+
self._register_api_namespaces(api)
|
203
|
+
|
204
|
+
except Exception as e:
|
205
|
+
api = None
|
206
|
+
logging.exception(f"Unable to initialize API ... {e}")
|
207
|
+
|
208
|
+
# Register the Sermos Core API as an extension for use in Client App
|
209
|
+
app.extensions.setdefault('sermos_core_api', api)
|
210
|
+
|
211
|
+
@staticmethod
|
212
|
+
def _register_api_namespaces(api: Api):
|
213
|
+
""" Register Default API namespaces
|
214
|
+
TODO add metrics APIs
|
215
|
+
"""
|
216
|
+
from pypeline.flask.api.pipelines import bp as pipelinesbp
|
217
|
+
api.register_blueprint(pipelinesbp)
|
218
|
+
from pypeline.flask.api.schedules import bp as schedulesbp
|
219
|
+
api.register_blueprint(schedulesbp)
|