compose-runner 0.6.1__py2.py3-none-any.whl → 0.6.2rc1__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.
- compose_runner/_version.py +2 -2
- compose_runner/aws_lambda/common.py +60 -0
- compose_runner/aws_lambda/log_poll_handler.py +15 -39
- compose_runner/aws_lambda/results_handler.py +13 -38
- compose_runner/aws_lambda/run_handler.py +83 -105
- compose_runner/aws_lambda/status_handler.py +102 -0
- compose_runner/ecs_task.py +145 -0
- compose_runner/tests/test_lambda_handlers.py +87 -30
- compose_runner-0.6.2rc1.dist-info/METADATA +79 -0
- {compose_runner-0.6.1.dist-info → compose_runner-0.6.2rc1.dist-info}/RECORD +13 -10
- compose_runner-0.6.1.dist-info/METADATA +0 -62
- {compose_runner-0.6.1.dist-info → compose_runner-0.6.2rc1.dist-info}/WHEEL +0 -0
- {compose_runner-0.6.1.dist-info → compose_runner-0.6.2rc1.dist-info}/entry_points.txt +0 -0
- {compose_runner-0.6.1.dist-info → compose_runner-0.6.2rc1.dist-info}/licenses/LICENSE +0 -0
compose_runner/_version.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.6.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 6,
|
|
31
|
+
__version__ = version = '0.6.2rc1'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 6, 2, 'rc1')
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_http_event(event: Any) -> bool:
|
|
10
|
+
return isinstance(event, dict) and "requestContext" in event
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _decode_body(event: Dict[str, Any]) -> Optional[str]:
|
|
14
|
+
body = event.get("body")
|
|
15
|
+
if not body:
|
|
16
|
+
return None
|
|
17
|
+
if event.get("isBase64Encoded"):
|
|
18
|
+
return base64.b64decode(body).decode("utf-8")
|
|
19
|
+
return body
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def extract_payload(event: Dict[str, Any]) -> Dict[str, Any]:
|
|
23
|
+
if not is_http_event(event):
|
|
24
|
+
return event
|
|
25
|
+
body = _decode_body(event)
|
|
26
|
+
if not body:
|
|
27
|
+
return {}
|
|
28
|
+
return json.loads(body)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def http_response(body: Dict[str, Any], status_code: int = 200) -> Dict[str, Any]:
|
|
32
|
+
return {
|
|
33
|
+
"statusCode": status_code,
|
|
34
|
+
"headers": {"Content-Type": "application/json"},
|
|
35
|
+
"body": json.dumps(body),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class LambdaRequest:
|
|
41
|
+
raw_event: Any
|
|
42
|
+
payload: Dict[str, Any]
|
|
43
|
+
is_http: bool
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def parse(cls, event: Any) -> "LambdaRequest":
|
|
47
|
+
payload = extract_payload(event)
|
|
48
|
+
return cls(raw_event=event, payload=payload, is_http=is_http_event(event))
|
|
49
|
+
|
|
50
|
+
def respond(self, body: Dict[str, Any], status_code: int = 200) -> Dict[str, Any]:
|
|
51
|
+
if self.is_http:
|
|
52
|
+
return http_response(body, status_code)
|
|
53
|
+
return body
|
|
54
|
+
|
|
55
|
+
def bad_request(self, message: str, status_code: int = 400) -> Dict[str, Any]:
|
|
56
|
+
return self.respond({"status": "FAILED", "error": message}, status_code=status_code)
|
|
57
|
+
|
|
58
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
59
|
+
return self.payload.get(key, default)
|
|
60
|
+
|
|
@@ -2,52 +2,30 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import time
|
|
5
|
-
import base64
|
|
6
|
-
import json
|
|
7
5
|
from typing import Any, Dict, List
|
|
8
6
|
|
|
9
7
|
import boto3
|
|
10
8
|
|
|
9
|
+
from compose_runner.aws_lambda.common import LambdaRequest
|
|
10
|
+
|
|
11
11
|
_LOGS_CLIENT = boto3.client("logs", region_name=os.environ.get("AWS_REGION", "us-east-1"))
|
|
12
12
|
|
|
13
13
|
LOG_GROUP_ENV = "RUNNER_LOG_GROUP"
|
|
14
14
|
DEFAULT_LOOKBACK_MS_ENV = "DEFAULT_LOOKBACK_MS"
|
|
15
15
|
|
|
16
|
-
def _is_http_event(event: Any) -> bool:
|
|
17
|
-
return isinstance(event, dict) and "requestContext" in event
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _extract_payload(event: Dict[str, Any]) -> Dict[str, Any]:
|
|
21
|
-
if not _is_http_event(event):
|
|
22
|
-
return event
|
|
23
|
-
body = event.get("body")
|
|
24
|
-
if not body:
|
|
25
|
-
return {}
|
|
26
|
-
if event.get("isBase64Encoded"):
|
|
27
|
-
body = base64.b64decode(body).decode("utf-8")
|
|
28
|
-
return json.loads(body)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _http_response(body: Dict[str, Any], status_code: int = 200) -> Dict[str, Any]:
|
|
32
|
-
return {
|
|
33
|
-
"statusCode": status_code,
|
|
34
|
-
"headers": {"Content-Type": "application/json"},
|
|
35
|
-
"body": json.dumps(body),
|
|
36
|
-
}
|
|
37
|
-
|
|
38
16
|
|
|
39
17
|
def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if not
|
|
44
|
-
message = "Request payload must include '
|
|
45
|
-
if
|
|
46
|
-
return
|
|
18
|
+
request = LambdaRequest.parse(event)
|
|
19
|
+
payload = request.payload
|
|
20
|
+
artifact_prefix = payload.get("artifact_prefix")
|
|
21
|
+
if not artifact_prefix:
|
|
22
|
+
message = "Request payload must include 'artifact_prefix'."
|
|
23
|
+
if request.is_http:
|
|
24
|
+
return request.bad_request(message, status_code=400)
|
|
47
25
|
raise KeyError(message)
|
|
48
|
-
next_token =
|
|
49
|
-
start_time =
|
|
50
|
-
end_time =
|
|
26
|
+
next_token = payload.get("next_token")
|
|
27
|
+
start_time = payload.get("start_time")
|
|
28
|
+
end_time = payload.get("end_time")
|
|
51
29
|
|
|
52
30
|
log_group = os.environ[LOG_GROUP_ENV]
|
|
53
31
|
lookback_ms = int(os.environ.get(DEFAULT_LOOKBACK_MS_ENV, "3600000"))
|
|
@@ -60,7 +38,7 @@ def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
|
|
|
60
38
|
|
|
61
39
|
params: Dict[str, Any] = {
|
|
62
40
|
"logGroupName": log_group,
|
|
63
|
-
"filterPattern": f'"{
|
|
41
|
+
"filterPattern": f'"{artifact_prefix}"',
|
|
64
42
|
"startTime": int(start_time),
|
|
65
43
|
}
|
|
66
44
|
if end_time is not None:
|
|
@@ -75,10 +53,8 @@ def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
|
|
|
75
53
|
]
|
|
76
54
|
|
|
77
55
|
body = {
|
|
78
|
-
"
|
|
56
|
+
"artifact_prefix": artifact_prefix,
|
|
79
57
|
"events": events,
|
|
80
58
|
"next_token": response.get("nextToken"),
|
|
81
59
|
}
|
|
82
|
-
|
|
83
|
-
return _http_response(body)
|
|
84
|
-
return body
|
|
60
|
+
return request.respond(body)
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
import base64
|
|
5
|
-
import json
|
|
6
4
|
from datetime import datetime, timezone
|
|
7
5
|
from typing import Any, Dict, List
|
|
8
6
|
|
|
9
7
|
import boto3
|
|
10
8
|
|
|
9
|
+
from compose_runner.aws_lambda.common import LambdaRequest
|
|
10
|
+
|
|
11
11
|
_S3 = boto3.client("s3", region_name=os.environ.get("AWS_REGION", "us-east-1"))
|
|
12
12
|
|
|
13
13
|
RESULTS_BUCKET_ENV = "RESULTS_BUCKET"
|
|
@@ -21,44 +21,21 @@ def _serialize_dt(value: datetime) -> str:
|
|
|
21
21
|
return value.astimezone(timezone.utc).isoformat()
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
def _is_http_event(event: Any) -> bool:
|
|
25
|
-
return isinstance(event, dict) and "requestContext" in event
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def _extract_payload(event: Dict[str, Any]) -> Dict[str, Any]:
|
|
29
|
-
if not _is_http_event(event):
|
|
30
|
-
return event
|
|
31
|
-
body = event.get("body")
|
|
32
|
-
if not body:
|
|
33
|
-
return {}
|
|
34
|
-
if event.get("isBase64Encoded"):
|
|
35
|
-
body = base64.b64decode(body).decode("utf-8")
|
|
36
|
-
return json.loads(body)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _http_response(body: Dict[str, Any], status_code: int = 200) -> Dict[str, Any]:
|
|
40
|
-
return {
|
|
41
|
-
"statusCode": status_code,
|
|
42
|
-
"headers": {"Content-Type": "application/json"},
|
|
43
|
-
"body": json.dumps(body),
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
24
|
def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
|
|
48
|
-
|
|
49
|
-
|
|
25
|
+
request = LambdaRequest.parse(event)
|
|
26
|
+
payload = request.payload
|
|
50
27
|
bucket = os.environ[RESULTS_BUCKET_ENV]
|
|
51
28
|
prefix = os.environ.get(RESULTS_PREFIX_ENV)
|
|
52
29
|
|
|
53
|
-
|
|
54
|
-
if not
|
|
55
|
-
message = "Request payload must include '
|
|
56
|
-
if
|
|
57
|
-
return
|
|
30
|
+
artifact_prefix = payload.get("artifact_prefix")
|
|
31
|
+
if not artifact_prefix:
|
|
32
|
+
message = "Request payload must include 'artifact_prefix'."
|
|
33
|
+
if request.is_http:
|
|
34
|
+
return request.bad_request(message, status_code=400)
|
|
58
35
|
raise KeyError(message)
|
|
59
|
-
expires_in = int(
|
|
36
|
+
expires_in = int(payload.get("expires_in", DEFAULT_EXPIRES_IN))
|
|
60
37
|
|
|
61
|
-
key_prefix = f"{prefix.rstrip('/')}/{
|
|
38
|
+
key_prefix = f"{prefix.rstrip('/')}/{artifact_prefix}" if prefix else artifact_prefix
|
|
62
39
|
|
|
63
40
|
response = _S3.list_objects_v2(Bucket=bucket, Prefix=key_prefix)
|
|
64
41
|
contents = response.get("Contents", [])
|
|
@@ -84,11 +61,9 @@ def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
|
|
|
84
61
|
)
|
|
85
62
|
|
|
86
63
|
body = {
|
|
87
|
-
"
|
|
64
|
+
"artifact_prefix": artifact_prefix,
|
|
88
65
|
"artifacts": artifacts,
|
|
89
66
|
"bucket": bucket,
|
|
90
67
|
"prefix": key_prefix,
|
|
91
68
|
}
|
|
92
|
-
|
|
93
|
-
return _http_response(body)
|
|
94
|
-
return body
|
|
69
|
+
return request.respond(body)
|
|
@@ -3,45 +3,24 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
import logging
|
|
5
5
|
import os
|
|
6
|
-
import
|
|
7
|
-
from
|
|
8
|
-
from typing import Any, Dict, Iterable, Optional
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
9
8
|
|
|
10
9
|
import boto3
|
|
10
|
+
from botocore.exceptions import ClientError
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
NUMBA_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
14
|
-
os.environ["NUMBA_CACHE_DIR"] = str(NUMBA_CACHE_DIR)
|
|
15
|
-
|
|
16
|
-
from compose_runner.run import run as run_compose
|
|
12
|
+
from compose_runner.aws_lambda.common import LambdaRequest
|
|
17
13
|
|
|
18
14
|
logger = logging.getLogger(__name__)
|
|
19
15
|
logger.setLevel(logging.INFO)
|
|
20
16
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def _is_http_event(event: Any) -> bool:
|
|
25
|
-
return isinstance(event, dict) and "requestContext" in event
|
|
17
|
+
_SFN_CLIENT = boto3.client("stepfunctions", region_name=os.environ.get("AWS_REGION", "us-east-1"))
|
|
26
18
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if not body:
|
|
33
|
-
return {}
|
|
34
|
-
if event.get("isBase64Encoded"):
|
|
35
|
-
body = base64.b64decode(body).decode("utf-8")
|
|
36
|
-
return json.loads(body)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _http_response(body: Dict[str, Any], status_code: int = 200) -> Dict[str, Any]:
|
|
40
|
-
return {
|
|
41
|
-
"statusCode": status_code,
|
|
42
|
-
"headers": {"Content-Type": "application/json"},
|
|
43
|
-
"body": json.dumps(body),
|
|
44
|
-
}
|
|
19
|
+
STATE_MACHINE_ARN_ENV = "STATE_MACHINE_ARN"
|
|
20
|
+
RESULTS_BUCKET_ENV = "RESULTS_BUCKET"
|
|
21
|
+
RESULTS_PREFIX_ENV = "RESULTS_PREFIX"
|
|
22
|
+
NSC_KEY_ENV = "NSC_KEY"
|
|
23
|
+
NV_KEY_ENV = "NV_KEY"
|
|
45
24
|
|
|
46
25
|
|
|
47
26
|
def _log(job_id: str, message: str, **details: Any) -> None:
|
|
@@ -50,88 +29,87 @@ def _log(job_id: str, message: str, **details: Any) -> None:
|
|
|
50
29
|
logger.info(json.dumps(payload))
|
|
51
30
|
|
|
52
31
|
|
|
53
|
-
def
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
32
|
+
def _job_input(
|
|
33
|
+
payload: Dict[str, Any],
|
|
34
|
+
artifact_prefix: str,
|
|
35
|
+
bucket: Optional[str],
|
|
36
|
+
prefix: Optional[str],
|
|
37
|
+
nsc_key: Optional[str],
|
|
38
|
+
nv_key: Optional[str],
|
|
39
|
+
) -> Dict[str, Any]:
|
|
40
|
+
no_upload_flag = bool(payload.get("no_upload", False))
|
|
41
|
+
doc: Dict[str, Any] = {
|
|
42
|
+
"artifact_prefix": artifact_prefix,
|
|
43
|
+
"meta_analysis_id": payload["meta_analysis_id"],
|
|
44
|
+
"environment": payload.get("environment", "production"),
|
|
45
|
+
"no_upload": "true" if no_upload_flag else "false",
|
|
46
|
+
"results": {"bucket": bucket or "", "prefix": prefix or ""},
|
|
47
|
+
}
|
|
48
|
+
n_cores = payload.get("n_cores")
|
|
49
|
+
doc["n_cores"] = str(n_cores) if n_cores is not None else ""
|
|
50
|
+
if nsc_key is not None:
|
|
51
|
+
doc["nsc_key"] = nsc_key
|
|
52
|
+
else:
|
|
53
|
+
doc["nsc_key"] = ""
|
|
54
|
+
if nv_key is not None:
|
|
55
|
+
doc["nv_key"] = nv_key
|
|
56
|
+
else:
|
|
57
|
+
doc["nv_key"] = ""
|
|
58
|
+
return doc
|
|
64
59
|
|
|
65
60
|
|
|
66
61
|
def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
|
|
67
|
-
|
|
68
|
-
payload =
|
|
69
|
-
|
|
62
|
+
request = LambdaRequest.parse(event)
|
|
63
|
+
payload = request.payload
|
|
64
|
+
if STATE_MACHINE_ARN_ENV not in os.environ:
|
|
65
|
+
raise RuntimeError(f"{STATE_MACHINE_ARN_ENV} environment variable must be set.")
|
|
66
|
+
|
|
70
67
|
if "meta_analysis_id" not in payload:
|
|
71
68
|
message = "Request payload must include 'meta_analysis_id'."
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return _http_response(
|
|
75
|
-
{"job_id": job_id, "status": "FAILED", "error": message}, status_code=400
|
|
76
|
-
)
|
|
69
|
+
if request.is_http:
|
|
70
|
+
return request.bad_request(message, status_code=400)
|
|
77
71
|
raise KeyError(message)
|
|
78
|
-
meta_analysis_id = payload["meta_analysis_id"]
|
|
79
|
-
environment = payload.get("environment", "production")
|
|
80
|
-
nsc_key = payload.get("nsc_key") or os.environ.get("NSC_KEY")
|
|
81
|
-
nv_key = payload.get("nv_key") or os.environ.get("NV_KEY")
|
|
82
|
-
no_upload = bool(payload.get("no_upload", False))
|
|
83
|
-
n_cores = payload.get("n_cores")
|
|
84
72
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
73
|
+
artifact_prefix = payload.get("artifact_prefix") or str(uuid.uuid4())
|
|
74
|
+
bucket = os.environ.get(RESULTS_BUCKET_ENV)
|
|
75
|
+
prefix = os.environ.get(RESULTS_PREFIX_ENV)
|
|
76
|
+
nsc_key = payload.get("nsc_key") or os.environ.get(NSC_KEY_ENV)
|
|
77
|
+
nv_key = payload.get("nv_key") or os.environ.get(NV_KEY_ENV)
|
|
78
|
+
|
|
79
|
+
job_input = _job_input(payload, artifact_prefix, bucket, prefix, nsc_key, nv_key)
|
|
80
|
+
params = {
|
|
81
|
+
"stateMachineArn": os.environ[STATE_MACHINE_ARN_ENV],
|
|
82
|
+
"name": artifact_prefix,
|
|
83
|
+
"input": json.dumps(job_input),
|
|
84
|
+
}
|
|
90
85
|
|
|
91
|
-
_log(
|
|
92
|
-
job_id,
|
|
93
|
-
"workflow.start",
|
|
94
|
-
meta_analysis_id=meta_analysis_id,
|
|
95
|
-
environment=environment,
|
|
96
|
-
no_upload=no_upload,
|
|
97
|
-
)
|
|
98
86
|
try:
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
result_dir=str(result_dir),
|
|
103
|
-
nsc_key=nsc_key,
|
|
104
|
-
nv_key=nv_key,
|
|
105
|
-
no_upload=no_upload,
|
|
106
|
-
n_cores=n_cores,
|
|
107
|
-
)
|
|
108
|
-
_log(job_id, "workflow.completed", result_url=url)
|
|
109
|
-
|
|
110
|
-
if bucket:
|
|
111
|
-
_upload_results(job_id, result_dir, bucket, prefix)
|
|
112
|
-
_log(job_id, "artifacts.uploaded", bucket=bucket, prefix=prefix)
|
|
113
|
-
|
|
87
|
+
response = _SFN_CLIENT.start_execution(**params)
|
|
88
|
+
except _SFN_CLIENT.exceptions.ExecutionAlreadyExists as exc:
|
|
89
|
+
_log(artifact_prefix, "workflow.duplicate", error=str(exc))
|
|
114
90
|
body = {
|
|
115
|
-
"
|
|
116
|
-
"
|
|
117
|
-
"
|
|
118
|
-
"artifacts_bucket": bucket,
|
|
119
|
-
"artifacts_prefix": prefix,
|
|
91
|
+
"status": "FAILED",
|
|
92
|
+
"error": "A job with the provided artifact_prefix already exists.",
|
|
93
|
+
"artifact_prefix": artifact_prefix,
|
|
120
94
|
}
|
|
121
|
-
if
|
|
122
|
-
return
|
|
123
|
-
|
|
124
|
-
except
|
|
125
|
-
_log(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
)
|
|
130
|
-
raise
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
95
|
+
if request.is_http:
|
|
96
|
+
return request.respond(body, status_code=409)
|
|
97
|
+
raise ValueError(body["error"]) from exc
|
|
98
|
+
except ClientError as exc:
|
|
99
|
+
_log(artifact_prefix, "workflow.failed_to_queue", error=str(exc))
|
|
100
|
+
message = "Failed to start compose-runner job."
|
|
101
|
+
body = {"status": "FAILED", "error": message}
|
|
102
|
+
if request.is_http:
|
|
103
|
+
return request.respond(body, status_code=500)
|
|
104
|
+
raise RuntimeError(message) from exc
|
|
105
|
+
|
|
106
|
+
execution_arn = response["executionArn"]
|
|
107
|
+
_log(artifact_prefix, "workflow.queued", execution_arn=execution_arn)
|
|
108
|
+
|
|
109
|
+
body = {
|
|
110
|
+
"job_id": execution_arn,
|
|
111
|
+
"artifact_prefix": artifact_prefix,
|
|
112
|
+
"status": "SUBMITTED",
|
|
113
|
+
"status_url": f"/jobs/{execution_arn}",
|
|
114
|
+
}
|
|
115
|
+
return request.respond(body, status_code=202)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
import boto3
|
|
9
|
+
from botocore.exceptions import ClientError
|
|
10
|
+
|
|
11
|
+
from compose_runner.aws_lambda.common import LambdaRequest
|
|
12
|
+
|
|
13
|
+
_SFN = boto3.client("stepfunctions", region_name=os.environ.get("AWS_REGION", "us-east-1"))
|
|
14
|
+
_S3 = boto3.client("s3", region_name=os.environ.get("AWS_REGION", "us-east-1"))
|
|
15
|
+
|
|
16
|
+
RESULTS_BUCKET_ENV = "RESULTS_BUCKET"
|
|
17
|
+
RESULTS_PREFIX_ENV = "RESULTS_PREFIX"
|
|
18
|
+
METADATA_FILENAME = "metadata.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _serialize_dt(value: datetime) -> str:
|
|
22
|
+
return value.astimezone().isoformat()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _metadata_key(prefix: Optional[str], artifact_prefix: str) -> str:
|
|
26
|
+
if prefix:
|
|
27
|
+
return f"{prefix.rstrip('/')}/{artifact_prefix}/{METADATA_FILENAME}"
|
|
28
|
+
return f"{artifact_prefix}/{METADATA_FILENAME}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_metadata(bucket: str, prefix: Optional[str], artifact_prefix: str) -> Optional[Dict[str, Any]]:
|
|
32
|
+
key = _metadata_key(prefix, artifact_prefix)
|
|
33
|
+
try:
|
|
34
|
+
response = _S3.get_object(Bucket=bucket, Key=key)
|
|
35
|
+
except ClientError as error:
|
|
36
|
+
if error.response["Error"]["Code"] in {"NoSuchKey", "404"}:
|
|
37
|
+
return None
|
|
38
|
+
raise
|
|
39
|
+
data = response["Body"].read()
|
|
40
|
+
return json.loads(data.decode("utf-8"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_output(output: Optional[str]) -> Dict[str, Any]:
|
|
44
|
+
if not output:
|
|
45
|
+
return {}
|
|
46
|
+
try:
|
|
47
|
+
return json.loads(output)
|
|
48
|
+
except json.JSONDecodeError:
|
|
49
|
+
return {"raw_output": output}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
|
|
53
|
+
request = LambdaRequest.parse(event)
|
|
54
|
+
payload = request.payload
|
|
55
|
+
|
|
56
|
+
job_id = payload.get("job_id")
|
|
57
|
+
if not job_id:
|
|
58
|
+
message = "Request payload must include 'job_id'."
|
|
59
|
+
if request.is_http:
|
|
60
|
+
return request.bad_request(message, status_code=400)
|
|
61
|
+
raise KeyError(message)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
description = _SFN.describe_execution(executionArn=job_id)
|
|
65
|
+
except ClientError as error:
|
|
66
|
+
body = {"status": "FAILED", "error": error.response["Error"]["Message"]}
|
|
67
|
+
if request.is_http:
|
|
68
|
+
status_code = 404 if error.response["Error"]["Code"] == "ExecutionDoesNotExist" else 500
|
|
69
|
+
return request.respond(body, status_code=status_code)
|
|
70
|
+
raise
|
|
71
|
+
|
|
72
|
+
status = description["status"]
|
|
73
|
+
body: Dict[str, Any] = {
|
|
74
|
+
"job_id": job_id,
|
|
75
|
+
"status": status,
|
|
76
|
+
"start_time": _serialize_dt(description["startDate"]),
|
|
77
|
+
}
|
|
78
|
+
if "stopDate" in description:
|
|
79
|
+
body["stop_time"] = _serialize_dt(description["stopDate"])
|
|
80
|
+
|
|
81
|
+
output_doc = _parse_output(description.get("output"))
|
|
82
|
+
body["output"] = output_doc
|
|
83
|
+
|
|
84
|
+
artifact_prefix = description.get("name")
|
|
85
|
+
if not artifact_prefix:
|
|
86
|
+
raise ValueError("Execution does not expose a name; cannot determine artifact prefix.")
|
|
87
|
+
body["artifact_prefix"] = artifact_prefix
|
|
88
|
+
|
|
89
|
+
if status in {"SUCCEEDED", "FAILED"}:
|
|
90
|
+
results_info = output_doc.get("results") or {}
|
|
91
|
+
bucket = results_info.get("bucket") or os.environ.get(RESULTS_BUCKET_ENV)
|
|
92
|
+
prefix = results_info.get("prefix") or os.environ.get(RESULTS_PREFIX_ENV)
|
|
93
|
+
|
|
94
|
+
if bucket and artifact_prefix:
|
|
95
|
+
metadata = _load_metadata(bucket, prefix, artifact_prefix)
|
|
96
|
+
if metadata:
|
|
97
|
+
body["result"] = metadata
|
|
98
|
+
|
|
99
|
+
if status == "FAILED":
|
|
100
|
+
body["error"] = output_doc.get("error")
|
|
101
|
+
|
|
102
|
+
return request.respond(body)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, Iterable, Optional
|
|
9
|
+
|
|
10
|
+
import boto3
|
|
11
|
+
|
|
12
|
+
from compose_runner.run import run as run_compose
|
|
13
|
+
|
|
14
|
+
NUMBA_CACHE_DIR = Path(os.environ.get("NUMBA_CACHE_DIR", "/tmp/numba_cache"))
|
|
15
|
+
NUMBA_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
os.environ["NUMBA_CACHE_DIR"] = str(NUMBA_CACHE_DIR)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("compose_runner.ecs_task")
|
|
19
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
20
|
+
formatter = logging.Formatter("%(message)s")
|
|
21
|
+
handler.setFormatter(formatter)
|
|
22
|
+
logger.addHandler(handler)
|
|
23
|
+
logger.setLevel(logging.INFO)
|
|
24
|
+
|
|
25
|
+
_S3_CLIENT = boto3.client("s3", region_name=os.environ.get("AWS_REGION", "us-east-1"))
|
|
26
|
+
|
|
27
|
+
RESULTS_BUCKET_ENV = "RESULTS_BUCKET"
|
|
28
|
+
RESULTS_PREFIX_ENV = "RESULTS_PREFIX"
|
|
29
|
+
ARTIFACT_PREFIX_ENV = "ARTIFACT_PREFIX"
|
|
30
|
+
META_ANALYSIS_ENV = "META_ANALYSIS_ID"
|
|
31
|
+
ENVIRONMENT_ENV = "ENVIRONMENT"
|
|
32
|
+
NSC_KEY_ENV = "NSC_KEY"
|
|
33
|
+
NV_KEY_ENV = "NV_KEY"
|
|
34
|
+
NO_UPLOAD_ENV = "NO_UPLOAD"
|
|
35
|
+
N_CORES_ENV = "N_CORES"
|
|
36
|
+
DELETE_TMP_ENV = "DELETE_TMP"
|
|
37
|
+
METADATA_FILENAME = "metadata.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _log(artifact_prefix: str, message: str, **details: Any) -> None:
|
|
41
|
+
payload = {"artifact_prefix": artifact_prefix, "message": message, **details}
|
|
42
|
+
logger.info(json.dumps(payload))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _iter_result_files(result_dir: Path) -> Iterable[Path]:
|
|
46
|
+
for path in result_dir.iterdir():
|
|
47
|
+
if path.is_file():
|
|
48
|
+
yield path
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _upload_results(artifact_prefix: str, result_dir: Path, bucket: str, prefix: Optional[str]) -> None:
|
|
52
|
+
base_prefix = f"{prefix.rstrip('/')}/{artifact_prefix}" if prefix else artifact_prefix
|
|
53
|
+
for file_path in _iter_result_files(result_dir):
|
|
54
|
+
key = f"{base_prefix}/{file_path.name}"
|
|
55
|
+
_S3_CLIENT.upload_file(str(file_path), bucket, key)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _write_metadata(bucket: str, prefix: Optional[str], artifact_prefix: str, metadata: Dict[str, Any]) -> None:
|
|
59
|
+
base_prefix = f"{prefix.rstrip('/')}/{artifact_prefix}" if prefix else artifact_prefix
|
|
60
|
+
key = f"{base_prefix}/{METADATA_FILENAME}"
|
|
61
|
+
metadata["metadata_key"] = key
|
|
62
|
+
_S3_CLIENT.put_object(
|
|
63
|
+
Bucket=bucket,
|
|
64
|
+
Key=key,
|
|
65
|
+
Body=json.dumps(metadata).encode("utf-8"),
|
|
66
|
+
ContentType="application/json",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _bool_from_env(value: Optional[str]) -> bool:
|
|
71
|
+
if value is None:
|
|
72
|
+
return False
|
|
73
|
+
return value.lower() in {"1", "true", "t", "yes", "y"}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main() -> None:
|
|
77
|
+
if ARTIFACT_PREFIX_ENV not in os.environ:
|
|
78
|
+
raise RuntimeError(f"{ARTIFACT_PREFIX_ENV} environment variable must be set.")
|
|
79
|
+
if META_ANALYSIS_ENV not in os.environ:
|
|
80
|
+
raise RuntimeError(f"{META_ANALYSIS_ENV} environment variable must be set.")
|
|
81
|
+
|
|
82
|
+
artifact_prefix = os.environ[ARTIFACT_PREFIX_ENV]
|
|
83
|
+
meta_analysis_id = os.environ[META_ANALYSIS_ENV]
|
|
84
|
+
environment = os.environ.get(ENVIRONMENT_ENV, "production")
|
|
85
|
+
nsc_key = os.environ.get(NSC_KEY_ENV) or None
|
|
86
|
+
nv_key = os.environ.get(NV_KEY_ENV) or None
|
|
87
|
+
no_upload = _bool_from_env(os.environ.get(NO_UPLOAD_ENV))
|
|
88
|
+
n_cores_value = os.environ.get(N_CORES_ENV)
|
|
89
|
+
n_cores = int(n_cores_value) if n_cores_value else None
|
|
90
|
+
|
|
91
|
+
bucket = os.environ.get(RESULTS_BUCKET_ENV)
|
|
92
|
+
prefix = os.environ.get(RESULTS_PREFIX_ENV)
|
|
93
|
+
|
|
94
|
+
result_dir = Path("/tmp") / artifact_prefix
|
|
95
|
+
result_dir.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
|
|
97
|
+
_log(
|
|
98
|
+
artifact_prefix,
|
|
99
|
+
"workflow.start",
|
|
100
|
+
meta_analysis_id=meta_analysis_id,
|
|
101
|
+
environment=environment,
|
|
102
|
+
no_upload=no_upload,
|
|
103
|
+
)
|
|
104
|
+
try:
|
|
105
|
+
url, _ = run_compose(
|
|
106
|
+
meta_analysis_id=meta_analysis_id,
|
|
107
|
+
environment=environment,
|
|
108
|
+
result_dir=str(result_dir),
|
|
109
|
+
nsc_key=nsc_key,
|
|
110
|
+
nv_key=nv_key,
|
|
111
|
+
no_upload=no_upload,
|
|
112
|
+
n_cores=n_cores,
|
|
113
|
+
)
|
|
114
|
+
_log(artifact_prefix, "workflow.completed", result_url=url)
|
|
115
|
+
|
|
116
|
+
metadata: Dict[str, Any] = {
|
|
117
|
+
"artifact_prefix": artifact_prefix,
|
|
118
|
+
"meta_analysis_id": meta_analysis_id,
|
|
119
|
+
"result_url": url,
|
|
120
|
+
"artifacts_bucket": bucket,
|
|
121
|
+
"artifacts_prefix": prefix,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if bucket:
|
|
125
|
+
_upload_results(artifact_prefix, result_dir, bucket, prefix)
|
|
126
|
+
_log(artifact_prefix, "artifacts.uploaded", bucket=bucket, prefix=prefix)
|
|
127
|
+
_write_metadata(bucket, prefix, artifact_prefix, metadata)
|
|
128
|
+
_log(artifact_prefix, "metadata.written", bucket=bucket, prefix=prefix)
|
|
129
|
+
|
|
130
|
+
_log(artifact_prefix, "workflow.success", result_url=url)
|
|
131
|
+
except Exception as exc: # noqa: broad-except
|
|
132
|
+
_log(artifact_prefix, "workflow.failed", error=str(exc))
|
|
133
|
+
raise
|
|
134
|
+
finally:
|
|
135
|
+
delete_tmp = _bool_from_env(os.environ.get(DELETE_TMP_ENV, "true"))
|
|
136
|
+
if delete_tmp:
|
|
137
|
+
for path in _iter_result_files(result_dir):
|
|
138
|
+
try:
|
|
139
|
+
path.unlink()
|
|
140
|
+
except OSError:
|
|
141
|
+
_log(artifact_prefix, "cleanup.warning", file=str(path))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
main()
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
4
5
|
from typing import Any, Dict
|
|
5
6
|
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
from compose_runner.aws_lambda import log_poll_handler, results_handler, run_handler
|
|
7
|
+
from compose_runner.aws_lambda import log_poll_handler, results_handler, run_handler, status_handler
|
|
9
8
|
|
|
10
9
|
|
|
11
10
|
class DummyContext:
|
|
@@ -25,42 +24,49 @@ def _make_http_event(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
25
24
|
|
|
26
25
|
|
|
27
26
|
def test_run_handler_http_success(monkeypatch, tmp_path):
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def fake_run(**kwargs):
|
|
31
|
-
called.update(kwargs)
|
|
32
|
-
return "https://result/url", None
|
|
27
|
+
captured = {}
|
|
33
28
|
|
|
34
|
-
|
|
29
|
+
class FakeSFN:
|
|
30
|
+
def start_execution(self, **kwargs):
|
|
31
|
+
captured.update(kwargs)
|
|
32
|
+
return {"executionArn": "arn:aws:states:us-east-1:123:execution:state-machine:run-123"}
|
|
35
33
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
class exceptions:
|
|
35
|
+
class ExecutionAlreadyExists(Exception):
|
|
36
|
+
...
|
|
39
37
|
|
|
40
|
-
monkeypatch.setattr(run_handler, "
|
|
41
|
-
monkeypatch.
|
|
38
|
+
monkeypatch.setattr(run_handler, "_SFN_CLIENT", FakeSFN())
|
|
39
|
+
monkeypatch.setenv("STATE_MACHINE_ARN", "arn:aws:states:state-machine")
|
|
42
40
|
monkeypatch.setenv("RESULTS_BUCKET", "bucket")
|
|
43
41
|
monkeypatch.setenv("RESULTS_PREFIX", "prefix")
|
|
44
42
|
monkeypatch.setenv("NSC_KEY", "nsc")
|
|
45
43
|
monkeypatch.setenv("NV_KEY", "nv")
|
|
46
44
|
|
|
47
|
-
event = _make_http_event(
|
|
48
|
-
|
|
45
|
+
event = _make_http_event(
|
|
46
|
+
{"meta_analysis_id": "abc123", "environment": "production", "artifact_prefix": "artifact-123"}
|
|
47
|
+
)
|
|
48
|
+
context = DummyContext("unused")
|
|
49
49
|
|
|
50
50
|
response = run_handler.handler(event, context)
|
|
51
51
|
body = json.loads(response["body"])
|
|
52
52
|
|
|
53
|
-
assert response["statusCode"] ==
|
|
54
|
-
assert body["job_id"]
|
|
55
|
-
assert body["
|
|
56
|
-
assert
|
|
57
|
-
assert
|
|
58
|
-
|
|
59
|
-
assert
|
|
60
|
-
assert
|
|
53
|
+
assert response["statusCode"] == 202
|
|
54
|
+
assert body["job_id"].startswith("arn:aws:states")
|
|
55
|
+
assert body["artifact_prefix"] == "artifact-123"
|
|
56
|
+
assert body["status"] == "SUBMITTED"
|
|
57
|
+
assert captured["name"] == "artifact-123"
|
|
58
|
+
input_doc = json.loads(captured["input"])
|
|
59
|
+
assert input_doc["artifact_prefix"] == "artifact-123"
|
|
60
|
+
assert input_doc["meta_analysis_id"] == "abc123"
|
|
61
|
+
assert input_doc["environment"] == "production"
|
|
62
|
+
assert input_doc["results"]["bucket"] == "bucket"
|
|
63
|
+
assert input_doc["results"]["prefix"] == "prefix"
|
|
64
|
+
assert input_doc["nsc_key"] == "nsc"
|
|
65
|
+
assert input_doc["nv_key"] == "nv"
|
|
61
66
|
|
|
62
67
|
|
|
63
68
|
def test_run_handler_missing_meta_analysis(monkeypatch):
|
|
69
|
+
monkeypatch.setenv("STATE_MACHINE_ARN", "arn:aws:states:state-machine")
|
|
64
70
|
event = _make_http_event({"environment": "production"})
|
|
65
71
|
response = run_handler.handler(event, DummyContext())
|
|
66
72
|
body = json.loads(response["body"])
|
|
@@ -80,9 +86,9 @@ def test_log_poll_handler(monkeypatch):
|
|
|
80
86
|
monkeypatch.setenv("DEFAULT_LOOKBACK_MS", "1000")
|
|
81
87
|
monkeypatch.setattr(log_poll_handler, "_LOGS_CLIENT", FakeLogs())
|
|
82
88
|
|
|
83
|
-
event = {"
|
|
89
|
+
event = {"artifact_prefix": "id"}
|
|
84
90
|
result = log_poll_handler.handler(event, DummyContext())
|
|
85
|
-
assert result["
|
|
91
|
+
assert result["artifact_prefix"] == "id"
|
|
86
92
|
assert result["next_token"] == "token-1"
|
|
87
93
|
assert result["events"][0]["message"] == events_payload[0]["message"]
|
|
88
94
|
|
|
@@ -94,7 +100,7 @@ def test_log_poll_handler_http_missing_job_id(monkeypatch):
|
|
|
94
100
|
body = json.loads(response["body"])
|
|
95
101
|
assert response["statusCode"] == 400
|
|
96
102
|
assert body["status"] == "FAILED"
|
|
97
|
-
assert "
|
|
103
|
+
assert "artifact_prefix" in body["error"]
|
|
98
104
|
|
|
99
105
|
|
|
100
106
|
def test_results_handler(monkeypatch):
|
|
@@ -119,15 +125,66 @@ def test_results_handler(monkeypatch):
|
|
|
119
125
|
monkeypatch.setenv("RESULTS_PREFIX", "prefix")
|
|
120
126
|
monkeypatch.setattr(results_handler, "_S3", FakeS3())
|
|
121
127
|
|
|
122
|
-
event = _make_http_event({"
|
|
128
|
+
event = _make_http_event({"artifact_prefix": "id"})
|
|
123
129
|
response = results_handler.handler(event, DummyContext())
|
|
124
130
|
body = json.loads(response["body"])
|
|
125
131
|
assert response["statusCode"] == 200
|
|
126
|
-
assert body["
|
|
132
|
+
assert body["artifact_prefix"] == "id"
|
|
127
133
|
assert body["artifacts"][0]["url"] == "https://signed/url"
|
|
128
134
|
assert body["artifacts"][0]["filename"] == "file1.nii.gz"
|
|
129
135
|
|
|
130
136
|
|
|
137
|
+
def test_status_handler_succeeded(monkeypatch):
|
|
138
|
+
start = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
|
139
|
+
stop = datetime(2024, 1, 1, 1, tzinfo=timezone.utc)
|
|
140
|
+
output_payload = {"results": {"bucket": "bucket", "prefix": "prefix"}}
|
|
141
|
+
|
|
142
|
+
class FakeBody:
|
|
143
|
+
def __init__(self, data):
|
|
144
|
+
self._data = data
|
|
145
|
+
|
|
146
|
+
def read(self):
|
|
147
|
+
return self._data
|
|
148
|
+
|
|
149
|
+
class FakeSFN:
|
|
150
|
+
def describe_execution(self, **kwargs):
|
|
151
|
+
return {
|
|
152
|
+
"status": "SUCCEEDED",
|
|
153
|
+
"name": "artifact-1",
|
|
154
|
+
"startDate": start,
|
|
155
|
+
"stopDate": stop,
|
|
156
|
+
"output": json.dumps(output_payload),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
class FakeS3:
|
|
160
|
+
def get_object(self, Bucket, Key):
|
|
161
|
+
assert Bucket == "bucket"
|
|
162
|
+
assert Key == "prefix/artifact-1/metadata.json"
|
|
163
|
+
metadata = {"artifact_prefix": "artifact-1", "result_url": "https://results"}
|
|
164
|
+
return {"Body": FakeBody(json.dumps(metadata).encode("utf-8"))}
|
|
165
|
+
|
|
166
|
+
monkeypatch.setattr(status_handler, "_SFN", FakeSFN())
|
|
167
|
+
monkeypatch.setattr(status_handler, "_S3", FakeS3())
|
|
168
|
+
|
|
169
|
+
event = _make_http_event({"job_id": "arn:execution"})
|
|
170
|
+
response = status_handler.handler(event, DummyContext())
|
|
171
|
+
body = json.loads(response["body"])
|
|
172
|
+
|
|
173
|
+
assert response["statusCode"] == 200
|
|
174
|
+
assert body["status"] == "SUCCEEDED"
|
|
175
|
+
assert body["artifact_prefix"] == "artifact-1"
|
|
176
|
+
assert body["result"]["result_url"] == "https://results"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_status_handler_missing_job_id(monkeypatch):
|
|
180
|
+
event = _make_http_event({})
|
|
181
|
+
response = status_handler.handler(event, DummyContext())
|
|
182
|
+
body = json.loads(response["body"])
|
|
183
|
+
assert response["statusCode"] == 400
|
|
184
|
+
assert body["status"] == "FAILED"
|
|
185
|
+
assert "job_id" in body["error"]
|
|
186
|
+
|
|
187
|
+
|
|
131
188
|
def test_results_handler_missing_job_id(monkeypatch):
|
|
132
189
|
monkeypatch.setenv("RESULTS_BUCKET", "bucket")
|
|
133
190
|
event = _make_http_event({})
|
|
@@ -135,4 +192,4 @@ def test_results_handler_missing_job_id(monkeypatch):
|
|
|
135
192
|
body = json.loads(response["body"])
|
|
136
193
|
assert response["statusCode"] == 400
|
|
137
194
|
assert body["status"] == "FAILED"
|
|
138
|
-
assert "
|
|
195
|
+
assert "artifact_prefix" in body["error"]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: compose-runner
|
|
3
|
+
Version: 0.6.2rc1
|
|
4
|
+
Summary: A package for running neurosynth-compose analyses
|
|
5
|
+
Project-URL: Repository, https://github.com/neurostuff/compose-runner
|
|
6
|
+
Author-email: James Kent <jamesdkent21@gmail.com>
|
|
7
|
+
License: BSD 3-Clause License
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: meta-analysis,neuroimaging,neurosynth,neurosynth-compose
|
|
10
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Dist: click
|
|
13
|
+
Requires-Dist: nimare
|
|
14
|
+
Requires-Dist: numpy
|
|
15
|
+
Requires-Dist: sentry-sdk
|
|
16
|
+
Provides-Extra: aws
|
|
17
|
+
Requires-Dist: boto3; extra == 'aws'
|
|
18
|
+
Provides-Extra: tests
|
|
19
|
+
Requires-Dist: pytest; extra == 'tests'
|
|
20
|
+
Requires-Dist: pytest-recording; extra == 'tests'
|
|
21
|
+
Requires-Dist: vcrpy; extra == 'tests'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# compose-runner
|
|
25
|
+
|
|
26
|
+
Python package to execute meta-analyses created using neurosynth compose and NiMARE
|
|
27
|
+
as the meta-analysis execution engine.
|
|
28
|
+
|
|
29
|
+
## AWS Deployment
|
|
30
|
+
|
|
31
|
+
This repository includes an AWS CDK application that turns compose-runner into a
|
|
32
|
+
serverless batch pipeline using Step Functions, AWS Lambda, and ECS Fargate.
|
|
33
|
+
The deployed architecture works like this:
|
|
34
|
+
|
|
35
|
+
- `ComposeRunnerSubmit` (Lambda Function URL) accepts HTTP requests, validates
|
|
36
|
+
the meta-analysis payload, and starts a Step Functions execution. The response
|
|
37
|
+
is immediate and returns both a durable `job_id` (the execution ARN) and the
|
|
38
|
+
`artifact_prefix` used for S3 and log correlation.
|
|
39
|
+
- A Standard state machine runs a single Fargate task (`compose_runner.ecs_task`)
|
|
40
|
+
and waits for completion. The container downloads inputs, executes the
|
|
41
|
+
meta-analysis on up to 4 vCPU / 30 GiB of memory, uploads artifacts to S3, and
|
|
42
|
+
writes `metadata.json` into the same prefix.
|
|
43
|
+
- `ComposeRunnerStatus` (Lambda Function URL) wraps `DescribeExecution`, merges
|
|
44
|
+
metadata from S3, and exposes a simple status endpoint suitable for polling.
|
|
45
|
+
- `ComposeRunnerLogPoller` streams the ECS CloudWatch Logs for a given `artifact_prefix`,
|
|
46
|
+
while `ComposeRunnerResultsFetcher` returns presigned URLs for stored artifacts.
|
|
47
|
+
|
|
48
|
+
1. Create a virtual environment and install the CDK dependencies:
|
|
49
|
+
```bash
|
|
50
|
+
cd infra/cdk
|
|
51
|
+
python -m venv .venv
|
|
52
|
+
source .venv/bin/activate
|
|
53
|
+
pip install -r requirements.txt
|
|
54
|
+
```
|
|
55
|
+
2. (One-time per account/region) bootstrap the CDK environment:
|
|
56
|
+
```bash
|
|
57
|
+
cdk bootstrap
|
|
58
|
+
```
|
|
59
|
+
3. Deploy the stack (supplying the compose-runner version you want baked into the images):
|
|
60
|
+
```bash
|
|
61
|
+
cdk deploy \
|
|
62
|
+
-c composeRunnerVersion=$(hatch version) \
|
|
63
|
+
-c resultsPrefix=compose-runner/results \
|
|
64
|
+
-c taskCpu=4096 \
|
|
65
|
+
-c taskMemoryMiB=30720
|
|
66
|
+
```
|
|
67
|
+
Pass `-c resultsBucketName=<bucket>` to use an existing S3 bucket, or omit it
|
|
68
|
+
to let the stack create and retain a dedicated bucket. Additional knobs:
|
|
69
|
+
|
|
70
|
+
- `-c stateMachineTimeoutSeconds=7200` to control the max wall clock per run
|
|
71
|
+
- `-c submitTimeoutSeconds` / `-c statusTimeoutSeconds` / `-c pollTimeoutSeconds`
|
|
72
|
+
to tune Lambda timeouts
|
|
73
|
+
- `-c taskEphemeralStorageGiB` if the default 21 GiB scratch volume is insufficient
|
|
74
|
+
|
|
75
|
+
The deployment builds both the Lambda image (`aws_lambda/Dockerfile`) and the
|
|
76
|
+
Fargate task image (`Dockerfile`), provisions the Step Functions state machine,
|
|
77
|
+
and configures a public VPC so each task has outbound internet access.
|
|
78
|
+
The CloudFormation outputs list the HTTPS endpoints for submission, status,
|
|
79
|
+
logs, and artifact retrieval, alongside the Step Functions ARN.
|
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
compose_runner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
compose_runner/_version.py,sha256=
|
|
2
|
+
compose_runner/_version.py,sha256=PQv64kDBSWybHkMaDVivQRRmALSrws0RGcr_Z0k2xNY,714
|
|
3
3
|
compose_runner/cli.py,sha256=1tkxFgEe8Yk7VkzE8qxGmCGqLU7UbGin2VaP0AiZkVg,1101
|
|
4
|
+
compose_runner/ecs_task.py,sha256=5-DbbcwfAqqkYRUuPADIlYatp5NK70uJYCo06O3IcdM,4997
|
|
4
5
|
compose_runner/run.py,sha256=yIh8Fj8dfVKvahRl483qGOsDUoAS1FdsYrKZp_HknGo,18525
|
|
5
6
|
compose_runner/sentry.py,sha256=pjqwsZrXrKB0cCy-TL-_2eYJIqUU0aV-8e0SWUk-9Xw,320
|
|
6
7
|
compose_runner/aws_lambda/__init__.py,sha256=yZNXXv7gCPSrtLCEX5Qf4cnzSTS3fHPV6k-SyZwiZIA,48
|
|
7
|
-
compose_runner/aws_lambda/
|
|
8
|
-
compose_runner/aws_lambda/
|
|
9
|
-
compose_runner/aws_lambda/
|
|
8
|
+
compose_runner/aws_lambda/common.py,sha256=cA2G5lO4P8uVBqJaYcU6Y3P3t3syoTmk4SpLKZhAFo8,1688
|
|
9
|
+
compose_runner/aws_lambda/log_poll_handler.py,sha256=eEU-Ra_-17me3e4eqSTd2Nv_qoaOl7zi3kIxD58Tbek,1905
|
|
10
|
+
compose_runner/aws_lambda/results_handler.py,sha256=vSxs4nbWyBmkFFKRGIp5-T4W2hPh9zgj7uNH-e18aW8,2107
|
|
11
|
+
compose_runner/aws_lambda/run_handler.py,sha256=iZW35xqa9FBZQTxRBH0JOpYhQ3Si1eTkxFHB5VJ5drA,3981
|
|
12
|
+
compose_runner/aws_lambda/status_handler.py,sha256=K_VDyPYY3ExiyalDyf35nXi3UZzqj4AenWmlxkzWNXo,3423
|
|
10
13
|
compose_runner/tests/conftest.py,sha256=ijb1iw724izKMxrvclt5x7LljTGoBfHwSS-jIEUe-sQ,191
|
|
11
14
|
compose_runner/tests/test_cli.py,sha256=G3Kz7Nbl2voJ_luXPL7E6slkRNF9lmcpZ-nHBAqeL-M,290
|
|
12
|
-
compose_runner/tests/test_lambda_handlers.py,sha256=
|
|
15
|
+
compose_runner/tests/test_lambda_handlers.py,sha256=A13q8KEMzxFPdqQYOIEqhYvEwKN1-3SPtGN9nRvLvtU,7115
|
|
13
16
|
compose_runner/tests/test_run.py,sha256=Nhx7wz8XxQuxy3kT5yoE_S1Hw0Mgmfn8TWYOZXm1_Gg,1795
|
|
14
17
|
compose_runner/tests/cassettes/test_run/test_download_bundle.yaml,sha256=vgdGDqirjBHosQsspkaN5Ty6XqJbkYUAbGtdImym5xI,79304
|
|
15
18
|
compose_runner/tests/cassettes/test_run/test_run_database_workflow.yaml,sha256=ay0aHtU-nmVWvbmN_EIgO9MMkC4ZeQljKU8nkTXOoDw,8724312
|
|
16
19
|
compose_runner/tests/cassettes/test_run/test_run_group_comparison_workflow.yaml,sha256=FaZpMdcaM7TMgyueyZBGftm6ywUh1HhtGmCegXUmRFA,4029712
|
|
17
20
|
compose_runner/tests/cassettes/test_run/test_run_string_group_comparison_workflow.yaml,sha256=pcn6tQwrimhDtP8yJ3jFlsfEOnk8FWybYQr9IQ5A_KA,3233839
|
|
18
21
|
compose_runner/tests/cassettes/test_run/test_run_workflow.yaml,sha256=0Nk7eJWAmgYALG2ODrezbRhpYsc00JiuYVjXt3TUm5c,3857234
|
|
19
|
-
compose_runner-0.6.
|
|
20
|
-
compose_runner-0.6.
|
|
21
|
-
compose_runner-0.6.
|
|
22
|
-
compose_runner-0.6.
|
|
23
|
-
compose_runner-0.6.
|
|
22
|
+
compose_runner-0.6.2rc1.dist-info/METADATA,sha256=8_a_K_vWLHqO6Kz-KyARz2NOt0WZ2MfTBf-pHQ2MppE,3435
|
|
23
|
+
compose_runner-0.6.2rc1.dist-info/WHEEL,sha256=tkmg4JIqwd9H8mL30xA7crRmoStyCtGp0VWshokd1Jc,105
|
|
24
|
+
compose_runner-0.6.2rc1.dist-info/entry_points.txt,sha256=TyPmB9o2tSWw8L3mcach9r2EL7inRVXE9ew3_XReMIY,55
|
|
25
|
+
compose_runner-0.6.2rc1.dist-info/licenses/LICENSE,sha256=PeiWxrrRme2rIpPMV9vjgGe7UHEKCIcTb0KagYhnyqo,1313
|
|
26
|
+
compose_runner-0.6.2rc1.dist-info/RECORD,,
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: compose-runner
|
|
3
|
-
Version: 0.6.1
|
|
4
|
-
Summary: A package for running neurosynth-compose analyses
|
|
5
|
-
Project-URL: Repository, https://github.com/neurostuff/compose-runner
|
|
6
|
-
Author-email: James Kent <jamesdkent21@gmail.com>
|
|
7
|
-
License: BSD 3-Clause License
|
|
8
|
-
License-File: LICENSE
|
|
9
|
-
Keywords: meta-analysis,neuroimaging,neurosynth,neurosynth-compose
|
|
10
|
-
Classifier: License :: OSI Approved :: BSD License
|
|
11
|
-
Classifier: Programming Language :: Python :: 3
|
|
12
|
-
Requires-Dist: click
|
|
13
|
-
Requires-Dist: nimare
|
|
14
|
-
Requires-Dist: numpy
|
|
15
|
-
Requires-Dist: sentry-sdk
|
|
16
|
-
Provides-Extra: aws
|
|
17
|
-
Requires-Dist: boto3; extra == 'aws'
|
|
18
|
-
Provides-Extra: tests
|
|
19
|
-
Requires-Dist: pytest; extra == 'tests'
|
|
20
|
-
Requires-Dist: pytest-recording; extra == 'tests'
|
|
21
|
-
Requires-Dist: vcrpy; extra == 'tests'
|
|
22
|
-
Description-Content-Type: text/markdown
|
|
23
|
-
|
|
24
|
-
# compose-runner
|
|
25
|
-
|
|
26
|
-
Python package to execute meta-analyses created using neurosynth compose and NiMARE
|
|
27
|
-
as the meta-analysis execution engine.
|
|
28
|
-
|
|
29
|
-
## AWS Lambda Deployment
|
|
30
|
-
|
|
31
|
-
This repository includes an AWS CDK application for provisioning the Lambda-based
|
|
32
|
-
execution environment and log polling function.
|
|
33
|
-
|
|
34
|
-
1. Create a virtual environment and install the CDK dependencies:
|
|
35
|
-
```bash
|
|
36
|
-
cd infra/cdk
|
|
37
|
-
python -m venv .venv
|
|
38
|
-
source .venv/bin/activate
|
|
39
|
-
pip install -r requirements.txt
|
|
40
|
-
```
|
|
41
|
-
2. (One-time per account/region) bootstrap the CDK environment:
|
|
42
|
-
```bash
|
|
43
|
-
cdk bootstrap
|
|
44
|
-
```
|
|
45
|
-
3. Deploy the stack (supplying the compose-runner version you want baked into the Lambda image):
|
|
46
|
-
```bash
|
|
47
|
-
cdk deploy \
|
|
48
|
-
-c composeRunnerVersion=$(hatch version) \
|
|
49
|
-
-c resultsPrefix=compose-runner/results \
|
|
50
|
-
-c runMemorySize=3008 \
|
|
51
|
-
-c runTimeoutSeconds=900
|
|
52
|
-
```
|
|
53
|
-
The deployment output includes HTTPS endpoints for submitting runs (`ComposeRunnerFunctionUrl`), polling logs (`ComposeRunnerLogPollerFunctionUrl`), and fetching presigned S3 URLs (`ComposeRunnerResultsFunctionUrl`).
|
|
54
|
-
Omit `resultsBucketName` to let the stack create a managed bucket, or pass an
|
|
55
|
-
existing bucket name via `-c resultsBucketName=<bucket>`.
|
|
56
|
-
|
|
57
|
-
The deployment builds the Lambda container image from `aws_lambda/Dockerfile`,
|
|
58
|
-
creates two functions (`ComposeRunnerFunction` and `ComposeRunnerLogPoller`),
|
|
59
|
-
and provisions the S3 bucket used to store generated artifacts (including
|
|
60
|
-
`meta_results.pkl`). The log poller function expects clients to call it with a
|
|
61
|
-
job ID (the run Lambda invocation request ID) and returns filtered CloudWatch Logs
|
|
62
|
-
entries for that job.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|