quapp-hpc 0.0.1__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.
- quapp_hpc-0.0.1/LICENSE +8 -0
- quapp_hpc-0.0.1/PKG-INFO +141 -0
- quapp_hpc-0.0.1/README.md +109 -0
- quapp_hpc-0.0.1/pyproject.toml +33 -0
- quapp_hpc-0.0.1/quapp_hpc/__init__.py +3 -0
- quapp_hpc-0.0.1/quapp_hpc/component/__init__.py +0 -0
- quapp_hpc-0.0.1/quapp_hpc/component/backend/__init__.py +0 -0
- quapp_hpc-0.0.1/quapp_hpc/component/backend/hpc_invocation.py +43 -0
- quapp_hpc-0.0.1/quapp_hpc/component/backend/slurm_job_fetching.py +95 -0
- quapp_hpc-0.0.1/quapp_hpc/factory/__init__.py +0 -0
- quapp_hpc-0.0.1/quapp_hpc/factory/hpc_device_factory.py +19 -0
- quapp_hpc-0.0.1/quapp_hpc/factory/hpc_handler_factory.py +30 -0
- quapp_hpc-0.0.1/quapp_hpc/factory/hpc_provider_factory.py +24 -0
- quapp_hpc-0.0.1/quapp_hpc/handler/__init__.py +0 -0
- quapp_hpc-0.0.1/quapp_hpc/handler/invocation_handler.py +23 -0
- quapp_hpc-0.0.1/quapp_hpc/handler/job_fetching_handler.py +18 -0
- quapp_hpc-0.0.1/quapp_hpc/model/__init__.py +0 -0
- quapp_hpc-0.0.1/quapp_hpc/model/device/__init__.py +0 -0
- quapp_hpc-0.0.1/quapp_hpc/model/device/slurm_device.py +321 -0
- quapp_hpc-0.0.1/quapp_hpc/model/provider/__init__.py +0 -0
- quapp_hpc-0.0.1/quapp_hpc/model/provider/slurm_provider.py +40 -0
- quapp_hpc-0.0.1/quapp_hpc.egg-info/PKG-INFO +141 -0
- quapp_hpc-0.0.1/quapp_hpc.egg-info/SOURCES.txt +27 -0
- quapp_hpc-0.0.1/quapp_hpc.egg-info/dependency_links.txt +1 -0
- quapp_hpc-0.0.1/quapp_hpc.egg-info/requires.txt +10 -0
- quapp_hpc-0.0.1/quapp_hpc.egg-info/top_level.txt +2 -0
- quapp_hpc-0.0.1/quapp_slurm_hpc/__init__.py +0 -0
- quapp_hpc-0.0.1/quapp_slurm_hpc/runner.py +142 -0
- quapp_hpc-0.0.1/setup.cfg +4 -0
quapp_hpc-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
Copyright © CITYNOW Co. Ltd. All rights reserved.
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
5
|
+
|
|
6
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
quapp_hpc-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quapp-hpc
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Quapp HPC library — Slurm integration for Quapp Platform
|
|
5
|
+
Author-email: "CITYNOW Co. Ltd." <corp@citynow.vn>
|
|
6
|
+
License: The MIT License (MIT)
|
|
7
|
+
Copyright © CITYNOW Co. Ltd. All rights reserved.
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
14
|
+
Project-URL: Homepage, https://quapp.cloud/
|
|
15
|
+
Keywords: quapp,quapp-hpc,slurm,hpc
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Requires-Python: <3.13,>=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: quapp-common==0.0.13
|
|
23
|
+
Requires-Dist: requests>=2.31.0
|
|
24
|
+
Requires-Dist: boto3>=1.28.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: black; extra == "dev"
|
|
27
|
+
Requires-Dist: bumpver; extra == "dev"
|
|
28
|
+
Requires-Dist: isort; extra == "dev"
|
|
29
|
+
Requires-Dist: pip-tools; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# quapp-hpc
|
|
34
|
+
|
|
35
|
+
Python library cho Quapp HPC functions — cầu nối giữa Quapp FaaS platform và Slurm HPC cluster.
|
|
36
|
+
|
|
37
|
+
## Architecture
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
ksvc (Docker)
|
|
41
|
+
├── index.py FastAPI server
|
|
42
|
+
├── quapp_hpc/
|
|
43
|
+
│ ├── factory/
|
|
44
|
+
│ │ └── hpc_handler_factory.py Entry point cho user
|
|
45
|
+
│ ├── component/backend/
|
|
46
|
+
│ │ └── hpc_invocation.py Orchestrates job lifecycle
|
|
47
|
+
│ └── model/
|
|
48
|
+
│ ├── provider/slurm_provider.py Auth headers, base URL
|
|
49
|
+
│ └── device/slurm_device.py Submit → Poll → S3 download
|
|
50
|
+
└── function/
|
|
51
|
+
└── handler.py User-defined processing() + post_processing()
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Luồng thực thi
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
index.py nhận HTTP POST
|
|
58
|
+
→ HpcHandlerFactory.create_handler(event, processing_fn, post_processing_fn)
|
|
59
|
+
→ InvocationHandler.handle()
|
|
60
|
+
→ HpcInvocation.submit_job()
|
|
61
|
+
1. processing_fn(invocation_input) → bash script string
|
|
62
|
+
2. SlurmDevice._create_job(script) → POST Slurm REST API → slurm_job_id
|
|
63
|
+
3. SlurmDevice._get_job_result() → poll mỗi 30s → COMPLETED/FAILED
|
|
64
|
+
4. SlurmDevice._download_s3_result()→ boto3 get s3://$S3_BUCKET/$JOB_UUID/output.json
|
|
65
|
+
5. post_processing_fn(s3_result) → final response
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Environment variables (từ K8s Secret `slurm-credentials`)
|
|
69
|
+
|
|
70
|
+
| Var | Ví dụ | Mô tả |
|
|
71
|
+
|-----|-------|-------|
|
|
72
|
+
| `SLURM_API_URL` | `http://10.1.0.15:6820` | Slurm REST API base URL |
|
|
73
|
+
| `SLURM_JWT` | `eyJ...` | JWT token cho Slurm auth |
|
|
74
|
+
| `SLURM_USERNAME` | `quapp-svc` | Slurm username |
|
|
75
|
+
| `SLURM_ACCOUNT` | `quapp` | Slurm account/allocation |
|
|
76
|
+
| `S3_BUCKET` | `quapp-slurm-output-dev` | S3 bucket cho job output |
|
|
77
|
+
| `AWS_REGION` | `ap-southeast-1` | AWS region |
|
|
78
|
+
| `SLURM_POLL_SEC` | `30` | Polling interval (giây) |
|
|
79
|
+
| `SLURM_TIMEOUT_SEC` | `21600` | Max wait time (giây, default 6h) |
|
|
80
|
+
|
|
81
|
+
## invocation_input schema
|
|
82
|
+
|
|
83
|
+
Xem chi tiết tại [`../qapp-sdk-templates/slurm-hpc/README.md`](../../qapp-sdk-templates/slurm-hpc/README.md).
|
|
84
|
+
|
|
85
|
+
Tóm tắt:
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"resources": { "partition", "nodes", "cpus_per_task", "gpus", "memory_gb", "time_limit" },
|
|
89
|
+
"container": { "type": "sif"|"docker"|"none", "image": "..." },
|
|
90
|
+
"job": { "type": "script"|"command", "script"|"command": "...", "environment": {}, "input_s3_paths": [] }
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Slurm REST API
|
|
95
|
+
|
|
96
|
+
- Version: `v0.0.40`
|
|
97
|
+
- Submit: `POST {SLURM_API_URL}/slurm/v0.0.40/job/submit`
|
|
98
|
+
- Status: `GET {SLURM_API_URL}/slurm/v0.0.40/job/{job_id}`
|
|
99
|
+
- Auth headers: `X-SLURM-USER-NAME`, `X-SLURM-USER-TOKEN`
|
|
100
|
+
|
|
101
|
+
### Job state mapping
|
|
102
|
+
|
|
103
|
+
| Slurm state | Quapp state |
|
|
104
|
+
|---|---|
|
|
105
|
+
| PENDING, CONFIGURING, RUNNING, COMPLETING | RUNNING |
|
|
106
|
+
| COMPLETED | DONE |
|
|
107
|
+
| FAILED, CANCELLED, TIMEOUT, NODE_FAIL, PREEMPTED | ERROR |
|
|
108
|
+
|
|
109
|
+
## S3 output pattern
|
|
110
|
+
|
|
111
|
+
Job script phải upload kết quả:
|
|
112
|
+
```bash
|
|
113
|
+
aws s3 cp /tmp/output.json s3://$S3_BUCKET/$JOB_UUID/output.json
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`$JOB_UUID` và `$S3_BUCKET` được inject tự động bởi `SlurmDevice._create_job()` qua Slurm `environment` array.
|
|
117
|
+
|
|
118
|
+
## K8s Secret
|
|
119
|
+
|
|
120
|
+
```yaml
|
|
121
|
+
# infrastructure/quapp-job-scheduler/k8s/cts/slurm-secret.yaml
|
|
122
|
+
apiVersion: v1
|
|
123
|
+
kind: Secret
|
|
124
|
+
metadata:
|
|
125
|
+
name: slurm-credentials
|
|
126
|
+
namespace: quapp-functions-dev
|
|
127
|
+
stringData:
|
|
128
|
+
SLURM_JWT: "<generate: sudo scontrol token username=quapp-svc lifespan=2592000>"
|
|
129
|
+
SLURM_API_URL: "http://10.1.0.15:6820"
|
|
130
|
+
SLURM_USERNAME: "quapp-svc"
|
|
131
|
+
SLURM_ACCOUNT: "quapp"
|
|
132
|
+
S3_BUCKET: "quapp-slurm-output-dev"
|
|
133
|
+
AWS_REGION: "ap-southeast-1"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## DB seed required
|
|
137
|
+
|
|
138
|
+
Chạy script trước khi deploy:
|
|
139
|
+
```
|
|
140
|
+
infrastructure/quapp-functions-backend/docs/db/seed_slurm_hpc.sql
|
|
141
|
+
```
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# quapp-hpc
|
|
2
|
+
|
|
3
|
+
Python library cho Quapp HPC functions — cầu nối giữa Quapp FaaS platform và Slurm HPC cluster.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
ksvc (Docker)
|
|
9
|
+
├── index.py FastAPI server
|
|
10
|
+
├── quapp_hpc/
|
|
11
|
+
│ ├── factory/
|
|
12
|
+
│ │ └── hpc_handler_factory.py Entry point cho user
|
|
13
|
+
│ ├── component/backend/
|
|
14
|
+
│ │ └── hpc_invocation.py Orchestrates job lifecycle
|
|
15
|
+
│ └── model/
|
|
16
|
+
│ ├── provider/slurm_provider.py Auth headers, base URL
|
|
17
|
+
│ └── device/slurm_device.py Submit → Poll → S3 download
|
|
18
|
+
└── function/
|
|
19
|
+
└── handler.py User-defined processing() + post_processing()
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Luồng thực thi
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
index.py nhận HTTP POST
|
|
26
|
+
→ HpcHandlerFactory.create_handler(event, processing_fn, post_processing_fn)
|
|
27
|
+
→ InvocationHandler.handle()
|
|
28
|
+
→ HpcInvocation.submit_job()
|
|
29
|
+
1. processing_fn(invocation_input) → bash script string
|
|
30
|
+
2. SlurmDevice._create_job(script) → POST Slurm REST API → slurm_job_id
|
|
31
|
+
3. SlurmDevice._get_job_result() → poll mỗi 30s → COMPLETED/FAILED
|
|
32
|
+
4. SlurmDevice._download_s3_result()→ boto3 get s3://$S3_BUCKET/$JOB_UUID/output.json
|
|
33
|
+
5. post_processing_fn(s3_result) → final response
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Environment variables (từ K8s Secret `slurm-credentials`)
|
|
37
|
+
|
|
38
|
+
| Var | Ví dụ | Mô tả |
|
|
39
|
+
|-----|-------|-------|
|
|
40
|
+
| `SLURM_API_URL` | `http://10.1.0.15:6820` | Slurm REST API base URL |
|
|
41
|
+
| `SLURM_JWT` | `eyJ...` | JWT token cho Slurm auth |
|
|
42
|
+
| `SLURM_USERNAME` | `quapp-svc` | Slurm username |
|
|
43
|
+
| `SLURM_ACCOUNT` | `quapp` | Slurm account/allocation |
|
|
44
|
+
| `S3_BUCKET` | `quapp-slurm-output-dev` | S3 bucket cho job output |
|
|
45
|
+
| `AWS_REGION` | `ap-southeast-1` | AWS region |
|
|
46
|
+
| `SLURM_POLL_SEC` | `30` | Polling interval (giây) |
|
|
47
|
+
| `SLURM_TIMEOUT_SEC` | `21600` | Max wait time (giây, default 6h) |
|
|
48
|
+
|
|
49
|
+
## invocation_input schema
|
|
50
|
+
|
|
51
|
+
Xem chi tiết tại [`../qapp-sdk-templates/slurm-hpc/README.md`](../../qapp-sdk-templates/slurm-hpc/README.md).
|
|
52
|
+
|
|
53
|
+
Tóm tắt:
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"resources": { "partition", "nodes", "cpus_per_task", "gpus", "memory_gb", "time_limit" },
|
|
57
|
+
"container": { "type": "sif"|"docker"|"none", "image": "..." },
|
|
58
|
+
"job": { "type": "script"|"command", "script"|"command": "...", "environment": {}, "input_s3_paths": [] }
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Slurm REST API
|
|
63
|
+
|
|
64
|
+
- Version: `v0.0.40`
|
|
65
|
+
- Submit: `POST {SLURM_API_URL}/slurm/v0.0.40/job/submit`
|
|
66
|
+
- Status: `GET {SLURM_API_URL}/slurm/v0.0.40/job/{job_id}`
|
|
67
|
+
- Auth headers: `X-SLURM-USER-NAME`, `X-SLURM-USER-TOKEN`
|
|
68
|
+
|
|
69
|
+
### Job state mapping
|
|
70
|
+
|
|
71
|
+
| Slurm state | Quapp state |
|
|
72
|
+
|---|---|
|
|
73
|
+
| PENDING, CONFIGURING, RUNNING, COMPLETING | RUNNING |
|
|
74
|
+
| COMPLETED | DONE |
|
|
75
|
+
| FAILED, CANCELLED, TIMEOUT, NODE_FAIL, PREEMPTED | ERROR |
|
|
76
|
+
|
|
77
|
+
## S3 output pattern
|
|
78
|
+
|
|
79
|
+
Job script phải upload kết quả:
|
|
80
|
+
```bash
|
|
81
|
+
aws s3 cp /tmp/output.json s3://$S3_BUCKET/$JOB_UUID/output.json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`$JOB_UUID` và `$S3_BUCKET` được inject tự động bởi `SlurmDevice._create_job()` qua Slurm `environment` array.
|
|
85
|
+
|
|
86
|
+
## K8s Secret
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
# infrastructure/quapp-job-scheduler/k8s/cts/slurm-secret.yaml
|
|
90
|
+
apiVersion: v1
|
|
91
|
+
kind: Secret
|
|
92
|
+
metadata:
|
|
93
|
+
name: slurm-credentials
|
|
94
|
+
namespace: quapp-functions-dev
|
|
95
|
+
stringData:
|
|
96
|
+
SLURM_JWT: "<generate: sudo scontrol token username=quapp-svc lifespan=2592000>"
|
|
97
|
+
SLURM_API_URL: "http://10.1.0.15:6820"
|
|
98
|
+
SLURM_USERNAME: "quapp-svc"
|
|
99
|
+
SLURM_ACCOUNT: "quapp"
|
|
100
|
+
S3_BUCKET: "quapp-slurm-output-dev"
|
|
101
|
+
AWS_REGION: "ap-southeast-1"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## DB seed required
|
|
105
|
+
|
|
106
|
+
Chạy script trước khi deploy:
|
|
107
|
+
```
|
|
108
|
+
infrastructure/quapp-functions-backend/docs/db/seed_slurm_hpc.sql
|
|
109
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0.0", "wheel==0.45.1"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quapp-hpc"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Quapp HPC library — Slurm integration for Quapp Platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [{ name = "CITYNOW Co. Ltd.", email = "corp@citynow.vn" }]
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
classifiers = [
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
]
|
|
17
|
+
keywords = ["quapp", "quapp-hpc", "slurm", "hpc"]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"quapp-common==0.0.13",
|
|
20
|
+
"requests>=2.31.0",
|
|
21
|
+
"boto3>=1.28.0",
|
|
22
|
+
]
|
|
23
|
+
requires-python = ">=3.10,<3.13"
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = ["black", "bumpver", "isort", "pip-tools", "pytest"]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
include = ["quapp_hpc*", "quapp_slurm_hpc*"]
|
|
30
|
+
exclude = ["*.md", "*.yml", "*.yaml", "*.toml", "tests*", ".gitignore"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://quapp.cloud/"
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from quapp_common.component.backend.invocation import Invocation
|
|
2
|
+
from quapp_common.data.request.invocation_request import InvocationRequest
|
|
3
|
+
from quapp_common.model.provider.provider import Provider
|
|
4
|
+
|
|
5
|
+
from ...factory.hpc_device_factory import HpcDeviceFactory
|
|
6
|
+
from ...factory.hpc_provider_factory import HpcProviderFactory
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HpcInvocation(Invocation):
|
|
10
|
+
|
|
11
|
+
def __init__(self, request_data: InvocationRequest):
|
|
12
|
+
super().__init__(request_data)
|
|
13
|
+
raw = request_data.input or {}
|
|
14
|
+
job = raw.get("job", {})
|
|
15
|
+
# s3Bucket intentionally dropped — S3 bucket is system config, read by
|
|
16
|
+
# SlurmDevice from the S3_BUCKET env var (slurm-credentials secret).
|
|
17
|
+
# User input must not override system fields (HPC design philosophy).
|
|
18
|
+
self._hpc_config = {
|
|
19
|
+
"resources": raw.get("resources", {}),
|
|
20
|
+
"container": raw.get("container", {}),
|
|
21
|
+
"environment": job.get("environment", {}),
|
|
22
|
+
"input_s3_paths": job.get("input_s3_paths", []),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
def _export_circuit(self, circuit):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
def _get_qubit_amount(self, circuit) -> int:
|
|
29
|
+
return 0
|
|
30
|
+
|
|
31
|
+
def _create_provider(self) -> Provider:
|
|
32
|
+
return HpcProviderFactory.create_provider(
|
|
33
|
+
provider_type=self.backend_information.provider_tag,
|
|
34
|
+
authentication=self.backend_information.authentication,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def _create_device(self, provider: Provider):
|
|
38
|
+
return HpcDeviceFactory.create_device(
|
|
39
|
+
provider=provider,
|
|
40
|
+
device_specification=self.backend_information.device_name,
|
|
41
|
+
job_uuid=self.job_id,
|
|
42
|
+
hpc_config=self._hpc_config,
|
|
43
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import boto3
|
|
5
|
+
import requests
|
|
6
|
+
from quapp_common.component.backend.job_fetching import JobFetching
|
|
7
|
+
from quapp_common.config.logging_config import job_logger
|
|
8
|
+
from quapp_common.data.request.job_fetching_request import JobFetchingRequest
|
|
9
|
+
from quapp_common.enum.status.job_status import JobStatus
|
|
10
|
+
|
|
11
|
+
from ...model.provider.slurm_provider import SlurmProvider
|
|
12
|
+
|
|
13
|
+
SLURM_JWT = os.getenv("SLURM_JWT", "")
|
|
14
|
+
S3_BUCKET = os.getenv("S3_BUCKET", "quapp-slurm-output-dev")
|
|
15
|
+
AWS_REGION = os.getenv("AWS_REGION", "ap-southeast-1")
|
|
16
|
+
|
|
17
|
+
_SLURM_TERMINAL_TO_JOB_STATUS = {
|
|
18
|
+
"COMPLETED": JobStatus.DONE.value,
|
|
19
|
+
"FAILED": JobStatus.ERROR.value,
|
|
20
|
+
"CANCELLED": JobStatus.ERROR.value,
|
|
21
|
+
"TIMEOUT": JobStatus.ERROR.value,
|
|
22
|
+
"NODE_FAIL": JobStatus.ERROR.value,
|
|
23
|
+
"PREEMPTED": JobStatus.ERROR.value,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _SlurmJobResult:
|
|
28
|
+
"""Lazy S3 download of the Slurm job's output.json.
|
|
29
|
+
|
|
30
|
+
result() downloads from S3 only when called (after DONE is confirmed).
|
|
31
|
+
usage() returns None so JobFetching.__get_execution_time() doesn't raise.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, job_uuid: str, s3_bucket: str = S3_BUCKET, aws_region: str = AWS_REGION):
|
|
35
|
+
self._job_uuid = job_uuid
|
|
36
|
+
self._s3_bucket = s3_bucket
|
|
37
|
+
self._aws_region = aws_region
|
|
38
|
+
self._logger = job_logger(job_uuid)
|
|
39
|
+
|
|
40
|
+
def usage(self):
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
def result(self):
|
|
44
|
+
s3_key = f"{self._job_uuid}/output.json"
|
|
45
|
+
self._logger.info(f"Downloading result from s3://{self._s3_bucket}/{s3_key}")
|
|
46
|
+
s3 = boto3.client("s3", region_name=self._aws_region)
|
|
47
|
+
resp = s3.get_object(Bucket=self._s3_bucket, Key=s3_key)
|
|
48
|
+
return json.loads(resp["Body"].read())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SlurmJobFetching(JobFetching):
|
|
52
|
+
"""Polls Slurm REST API for job status (IBM-pattern, triggered by watchdog)."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, request_data: JobFetchingRequest):
|
|
55
|
+
super().__init__(request_data)
|
|
56
|
+
self.job_id = request_data.job_id
|
|
57
|
+
self._logger = job_logger(request_data.job_id)
|
|
58
|
+
|
|
59
|
+
# ── Abstract implementations ──────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
def _collect_provider(self) -> SlurmProvider:
|
|
62
|
+
jwt = (self.provider_authentication or {}).get("slurm_jwt") or SLURM_JWT
|
|
63
|
+
if not jwt:
|
|
64
|
+
raise ValueError("SLURM_JWT not set — cannot authenticate with Slurm API")
|
|
65
|
+
return SlurmProvider(jwt_token=jwt)
|
|
66
|
+
|
|
67
|
+
def _retrieve_job(self, provider: SlurmProvider) -> dict:
|
|
68
|
+
return {"slurm_job_id": self.provider_job_id, "provider": provider}
|
|
69
|
+
|
|
70
|
+
def _get_job_status(self, job: dict) -> str:
|
|
71
|
+
provider: SlurmProvider = job["provider"]
|
|
72
|
+
slurm_job_id = job["slurm_job_id"]
|
|
73
|
+
|
|
74
|
+
resp = requests.get(
|
|
75
|
+
f"{provider.base_url}/job/{slurm_job_id}",
|
|
76
|
+
headers=provider.auth_headers(),
|
|
77
|
+
timeout=15,
|
|
78
|
+
)
|
|
79
|
+
resp.raise_for_status()
|
|
80
|
+
data = resp.json()
|
|
81
|
+
|
|
82
|
+
jobs = data.get("jobs", [data])
|
|
83
|
+
if not jobs:
|
|
84
|
+
return "UNKNOWN"
|
|
85
|
+
|
|
86
|
+
raw_state = jobs[0].get("job_state", "UNKNOWN")
|
|
87
|
+
if isinstance(raw_state, list):
|
|
88
|
+
raw_state = raw_state[0] if raw_state else "UNKNOWN"
|
|
89
|
+
|
|
90
|
+
state = str(raw_state).strip()
|
|
91
|
+
self._logger.info(f"Slurm job {slurm_job_id} state: {state}")
|
|
92
|
+
return _SLURM_TERMINAL_TO_JOB_STATUS.get(state, state)
|
|
93
|
+
|
|
94
|
+
def _get_job_result(self, job: dict) -> _SlurmJobResult:
|
|
95
|
+
return _SlurmJobResult(job_uuid=self.job_id, s3_bucket=S3_BUCKET, aws_region=AWS_REGION)
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from quapp_common.config.logging_config import job_logger
|
|
2
|
+
from quapp_common.model.provider.provider import Provider
|
|
3
|
+
|
|
4
|
+
from ..model.device.slurm_device import SlurmDevice
|
|
5
|
+
|
|
6
|
+
logger = job_logger('HpcDeviceFactory')
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HpcDeviceFactory:
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
def create_device(
|
|
13
|
+
provider: Provider,
|
|
14
|
+
device_specification: str,
|
|
15
|
+
job_uuid: str,
|
|
16
|
+
hpc_config: dict = None,
|
|
17
|
+
) -> SlurmDevice:
|
|
18
|
+
logger.debug(f"Creating SlurmDevice: partition={device_specification}")
|
|
19
|
+
return SlurmDevice(provider, device_specification, job_uuid, hpc_config=hpc_config)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from quapp_common.config.logging_config import job_logger
|
|
2
|
+
from quapp_common.factory.handler_factory import HandlerFactory
|
|
3
|
+
from quapp_common.handler.handler import Handler
|
|
4
|
+
|
|
5
|
+
from ..handler.invocation_handler import InvocationHandler
|
|
6
|
+
from ..handler.job_fetching_handler import SlurmJobFetchingHandler
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HpcHandlerFactory(HandlerFactory):
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
def create_handler(event, circuit_preparation_fn, post_processing_fn) -> Handler:
|
|
13
|
+
request_data = event.json()
|
|
14
|
+
logger = job_logger(request_data.get("jobId"))
|
|
15
|
+
|
|
16
|
+
provider_job_id = request_data.get("providerJobId")
|
|
17
|
+
|
|
18
|
+
if provider_job_id:
|
|
19
|
+
logger.debug(f"HpcHandlerFactory: job fetching (slurmJobId={provider_job_id})")
|
|
20
|
+
return SlurmJobFetchingHandler(
|
|
21
|
+
request_data=request_data,
|
|
22
|
+
post_processing_fn=post_processing_fn,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
logger.debug("HpcHandlerFactory: initial invocation")
|
|
26
|
+
return InvocationHandler(
|
|
27
|
+
request_data=request_data,
|
|
28
|
+
circuit_preparation_fn=circuit_preparation_fn,
|
|
29
|
+
post_processing_fn=post_processing_fn,
|
|
30
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from quapp_common.enum.provider_tag import ProviderTag
|
|
3
|
+
from quapp_common.config.logging_config import job_logger
|
|
4
|
+
|
|
5
|
+
from ..model.provider.slurm_provider import SlurmProvider
|
|
6
|
+
|
|
7
|
+
logger = job_logger('HpcProviderFactory')
|
|
8
|
+
|
|
9
|
+
SLURM_JWT = os.getenv("SLURM_JWT", "")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HpcProviderFactory:
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def create_provider(provider_type: ProviderTag, authentication: dict):
|
|
16
|
+
logger.debug(f"Creating HPC provider: {provider_type}")
|
|
17
|
+
|
|
18
|
+
if provider_type == ProviderTag.QUAPP_HPC:
|
|
19
|
+
jwt = authentication.get("slurm_jwt") or SLURM_JWT
|
|
20
|
+
if not jwt:
|
|
21
|
+
raise ValueError("SLURM_JWT not set — cannot authenticate with Slurm API")
|
|
22
|
+
return SlurmProvider(jwt_token=jwt)
|
|
23
|
+
|
|
24
|
+
raise NotImplementedError(f"Unsupported HPC provider: {provider_type}")
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from quapp_common.data.request.invocation_request import InvocationRequest
|
|
2
|
+
from quapp_common.handler.handler import Handler
|
|
3
|
+
|
|
4
|
+
from ..component.backend.hpc_invocation import HpcInvocation
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class InvocationHandler(Handler):
|
|
8
|
+
|
|
9
|
+
def __init__(self, request_data: dict, circuit_preparation_fn, post_processing_fn):
|
|
10
|
+
super().__init__(request_data, post_processing_fn)
|
|
11
|
+
self.circuit_preparation_fn = circuit_preparation_fn
|
|
12
|
+
|
|
13
|
+
def handle(self):
|
|
14
|
+
self.logger.debug("HPC InvocationHandler: start")
|
|
15
|
+
try:
|
|
16
|
+
invocation_request = InvocationRequest(self.request_data)
|
|
17
|
+
HpcInvocation(invocation_request).submit_job(
|
|
18
|
+
circuit_preparation_fn=self.circuit_preparation_fn,
|
|
19
|
+
post_processing_fn=self.post_processing_fn,
|
|
20
|
+
)
|
|
21
|
+
except Exception as exc:
|
|
22
|
+
self.logger.exception(f"HPC invocation failed: {exc}")
|
|
23
|
+
raise
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from quapp_common.data.request.job_fetching_request import JobFetchingRequest
|
|
2
|
+
from quapp_common.handler.handler import Handler
|
|
3
|
+
|
|
4
|
+
from ..component.backend.slurm_job_fetching import SlurmJobFetching
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SlurmJobFetchingHandler(Handler):
|
|
8
|
+
"""Handles watchdog-triggered job status polls for Slurm jobs."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, request_data: dict, post_processing_fn):
|
|
11
|
+
super().__init__(request_data, post_processing_fn)
|
|
12
|
+
|
|
13
|
+
def handle(self):
|
|
14
|
+
self.logger.debug("SlurmJobFetchingHandler: start")
|
|
15
|
+
request = JobFetchingRequest(self.request_data)
|
|
16
|
+
return SlurmJobFetching(request).fetch(
|
|
17
|
+
post_processing_fn=self.post_processing_fn
|
|
18
|
+
)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import shlex
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import boto3
|
|
8
|
+
import requests
|
|
9
|
+
from quapp_common.config.logging_config import job_logger
|
|
10
|
+
from quapp_common.data.device.circuit_running_option import CircuitRunningOption
|
|
11
|
+
from quapp_common.enum.status.job_status import JobStatus
|
|
12
|
+
from quapp_common.model.device.device import Device
|
|
13
|
+
from quapp_common.model.provider.provider import Provider
|
|
14
|
+
|
|
15
|
+
from ..provider.slurm_provider import SlurmProvider, SLURM_ACCOUNT
|
|
16
|
+
|
|
17
|
+
# ── System config (read from K8s secret slurm-credentials at ksvc startup) ───
|
|
18
|
+
S3_BUCKET = os.getenv("S3_BUCKET", "quapp-slurm-output-dev")
|
|
19
|
+
AWS_REGION = os.getenv("AWS_REGION", "ap-southeast-1")
|
|
20
|
+
SLURM_POLL_SEC = int(os.getenv("SLURM_POLL_SEC", "30"))
|
|
21
|
+
SLURM_TIMEOUT_SEC = int(os.getenv("SLURM_TIMEOUT_SEC", "21600")) # 6 hours
|
|
22
|
+
SLURM_TIME_LIMIT = int(os.getenv("SLURM_TIME_LIMIT_MIN", "60"))
|
|
23
|
+
|
|
24
|
+
# ── Compute runtime config (injected by builder when creating ksvc) ──────────
|
|
25
|
+
# Builder sets these env vars based on the function's qapp_compute_runtime row.
|
|
26
|
+
# The path is resolved per-function: /data/containers/functions/<fn_hash>_<fn_tag>.sif
|
|
27
|
+
# CTS knative_builder/function/handler.sh adds these via --env to `kn ksvc create`.
|
|
28
|
+
COMPUTE_SIF_PATH = os.getenv("COMPUTE_SIF_PATH")
|
|
29
|
+
COMPUTE_ENTRYPOINT = os.getenv("COMPUTE_ENTRYPOINT", "python3 /tmp/quapp_job.py")
|
|
30
|
+
COMPUTE_SCRIPT_EXT = os.getenv("COMPUTE_SCRIPT_EXT", "py")
|
|
31
|
+
|
|
32
|
+
_TERMINAL_STATES = {"COMPLETED", "FAILED", "CANCELLED", "TIMEOUT", "NODE_FAIL", "PREEMPTED"}
|
|
33
|
+
_DONE_STATE = "COMPLETED"
|
|
34
|
+
|
|
35
|
+
_SLURM_TERMINAL_TO_JOB_STATUS = {
|
|
36
|
+
"COMPLETED": JobStatus.DONE.value,
|
|
37
|
+
"FAILED": JobStatus.ERROR.value,
|
|
38
|
+
"CANCELLED": JobStatus.ERROR.value,
|
|
39
|
+
"TIMEOUT": JobStatus.ERROR.value,
|
|
40
|
+
"NODE_FAIL": JobStatus.ERROR.value,
|
|
41
|
+
"PREEMPTED": JobStatus.ERROR.value,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_COLLECT_PY = (
|
|
45
|
+
"import json, os; "
|
|
46
|
+
"ec = int(open('/tmp/quapp_exit_code.txt').read().strip()) "
|
|
47
|
+
"if os.path.exists('/tmp/quapp_exit_code.txt') else 0; "
|
|
48
|
+
"out = open('/tmp/quapp_stdout.txt').read() "
|
|
49
|
+
"if os.path.exists('/tmp/quapp_stdout.txt') else ''; "
|
|
50
|
+
"err = open('/tmp/quapp_stderr.txt').read() "
|
|
51
|
+
"if os.path.exists('/tmp/quapp_stderr.txt') else ''; "
|
|
52
|
+
"json.dump({'exit_code': ec, 'stdout': out, 'stderr': err}, open('/tmp/output.json','w'))"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve_compute_container(container_cfg: dict) -> tuple[str, str, str]:
|
|
57
|
+
"""Resolve Apptainer image path, entrypoint command, and script extension.
|
|
58
|
+
|
|
59
|
+
Resolution order (first match wins):
|
|
60
|
+
1. User override via invocation `container`:
|
|
61
|
+
container.type = 'sif' + container.image → /data/containers/<image>
|
|
62
|
+
container.type = 'docker' + container.image → docker://<image>
|
|
63
|
+
2. Builder-injected env vars (per-function SIF from build step):
|
|
64
|
+
COMPUTE_SIF_PATH + COMPUTE_ENTRYPOINT + COMPUTE_SCRIPT_EXT
|
|
65
|
+
3. Hard error — no valid configuration. Container is mandatory; running user
|
|
66
|
+
scripts on bare metal is not supported (see HPC design philosophy).
|
|
67
|
+
"""
|
|
68
|
+
container_type = (container_cfg or {}).get("type", "").lower()
|
|
69
|
+
user_image = (container_cfg or {}).get("image", "")
|
|
70
|
+
user_entry = (container_cfg or {}).get("entrypoint")
|
|
71
|
+
user_ext = (container_cfg or {}).get("script_extension")
|
|
72
|
+
|
|
73
|
+
# 1. User override — explicit container choice in invocation input
|
|
74
|
+
if container_type in ("sif", "docker"):
|
|
75
|
+
if not user_image:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"container.type={container_type!r} requires container.image"
|
|
78
|
+
)
|
|
79
|
+
if container_type == "sif":
|
|
80
|
+
sif_path = f"/data/containers/{user_image}"
|
|
81
|
+
else:
|
|
82
|
+
sif_path = f"docker://{user_image}"
|
|
83
|
+
return (
|
|
84
|
+
sif_path,
|
|
85
|
+
user_entry or COMPUTE_ENTRYPOINT,
|
|
86
|
+
user_ext or COMPUTE_SCRIPT_EXT,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# 2. Builder-injected default — per-function SIF built at deploy time
|
|
90
|
+
if COMPUTE_SIF_PATH:
|
|
91
|
+
return (COMPUTE_SIF_PATH, COMPUTE_ENTRYPOINT, COMPUTE_SCRIPT_EXT)
|
|
92
|
+
|
|
93
|
+
# 3. No path resolved → fail explicitly. Running outside a container is not
|
|
94
|
+
# supported per the Quapp-HPC design philosophy (container-first sandboxing).
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
"No compute container configured. Either the function pod is missing the "
|
|
97
|
+
"COMPUTE_SIF_PATH env var (builder did not produce a per-function SIF — "
|
|
98
|
+
"rebuild required) or the invocation must explicitly provide "
|
|
99
|
+
"container.type and container.image."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SlurmDevice(Device):
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
provider: Provider,
|
|
108
|
+
device_specification: str,
|
|
109
|
+
job_uuid: str,
|
|
110
|
+
hpc_config: dict = None,
|
|
111
|
+
):
|
|
112
|
+
super().__init__(provider, device_specification)
|
|
113
|
+
self.job_uuid: str = job_uuid
|
|
114
|
+
self.slurm: SlurmProvider = provider
|
|
115
|
+
self.logger = job_logger(job_uuid)
|
|
116
|
+
self.hpc_config = hpc_config or {}
|
|
117
|
+
# S3 bucket is a system field — read only from env (no input override).
|
|
118
|
+
# Per HPC design philosophy: system fields are not user-controlled.
|
|
119
|
+
self.s3_bucket = S3_BUCKET
|
|
120
|
+
|
|
121
|
+
# ── Abstract method implementations ──────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
def _is_simulator(self) -> bool:
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
def _create_job(self, circuit: str, options: CircuitRunningOption) -> dict:
|
|
127
|
+
"""Wrap circuit (user script string) in SBATCH infrastructure and submit to Slurm REST API."""
|
|
128
|
+
bash = self._build_sbatch_script(circuit)
|
|
129
|
+
|
|
130
|
+
payload = {
|
|
131
|
+
"job": {
|
|
132
|
+
"name": f"quapp-{self.job_uuid[:8]}",
|
|
133
|
+
"account": SLURM_ACCOUNT,
|
|
134
|
+
"environment": [
|
|
135
|
+
"PATH=/usr/bin:/bin:/usr/local/bin",
|
|
136
|
+
f"AWS_DEFAULT_REGION={AWS_REGION}",
|
|
137
|
+
],
|
|
138
|
+
"time_limit": {
|
|
139
|
+
"number": SLURM_TIME_LIMIT,
|
|
140
|
+
"set": True,
|
|
141
|
+
"infinite": False,
|
|
142
|
+
},
|
|
143
|
+
"partition": self.device,
|
|
144
|
+
"current_working_directory": "/data/jobs",
|
|
145
|
+
"standard_output": f"/data/jobs/{self.job_uuid}.out",
|
|
146
|
+
"standard_error": f"/data/jobs/{self.job_uuid}.err",
|
|
147
|
+
},
|
|
148
|
+
"script": bash,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
resp = requests.post(
|
|
152
|
+
f"{self.slurm.base_url}/job/submit",
|
|
153
|
+
headers=self.slurm.auth_headers(),
|
|
154
|
+
json=payload,
|
|
155
|
+
timeout=30,
|
|
156
|
+
)
|
|
157
|
+
resp.raise_for_status()
|
|
158
|
+
data = resp.json()
|
|
159
|
+
slurm_job_id = data.get("job_id") or data.get("jobId")
|
|
160
|
+
|
|
161
|
+
if not slurm_job_id:
|
|
162
|
+
raise RuntimeError(f"Slurm submit response missing job_id: {data}")
|
|
163
|
+
|
|
164
|
+
self.logger.info(f"Slurm job submitted: slurm_job_id={slurm_job_id}")
|
|
165
|
+
return {"slurm_job_id": str(slurm_job_id), "job_uuid": self.job_uuid}
|
|
166
|
+
|
|
167
|
+
def _get_provider_job_id(self, job: dict) -> str:
|
|
168
|
+
return job["slurm_job_id"]
|
|
169
|
+
|
|
170
|
+
def _get_job_status(self, job: dict) -> str:
|
|
171
|
+
state = self._fetch_slurm_state(job["slurm_job_id"])
|
|
172
|
+
return _SLURM_TERMINAL_TO_JOB_STATUS.get(state, state)
|
|
173
|
+
|
|
174
|
+
def _get_job_result(self, job: dict):
|
|
175
|
+
"""Block until Slurm job finishes, then download result from S3."""
|
|
176
|
+
slurm_job_id = job["slurm_job_id"]
|
|
177
|
+
job_uuid = job["job_uuid"]
|
|
178
|
+
|
|
179
|
+
elapsed = 0
|
|
180
|
+
while elapsed < SLURM_TIMEOUT_SEC:
|
|
181
|
+
state = self._fetch_slurm_state(slurm_job_id)
|
|
182
|
+
self.logger.debug(f"Slurm job {slurm_job_id} state: {state}")
|
|
183
|
+
|
|
184
|
+
if state == _DONE_STATE:
|
|
185
|
+
return self._download_s3_result(job_uuid)
|
|
186
|
+
|
|
187
|
+
if state in _TERMINAL_STATES:
|
|
188
|
+
raise RuntimeError(f"Slurm job {slurm_job_id} ended with state: {state}")
|
|
189
|
+
|
|
190
|
+
time.sleep(SLURM_POLL_SEC)
|
|
191
|
+
elapsed += SLURM_POLL_SEC
|
|
192
|
+
|
|
193
|
+
raise TimeoutError(f"Slurm job {slurm_job_id} timed out after {SLURM_TIMEOUT_SEC}s")
|
|
194
|
+
|
|
195
|
+
def _produce_histogram_data(self, job_result) -> None:
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
def _calculate_execution_time(self, job_result) -> None:
|
|
199
|
+
self.execution_time = None
|
|
200
|
+
|
|
201
|
+
# ── SBATCH script builder ─────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
def _build_sbatch_script(self, user_script: str) -> str:
|
|
204
|
+
"""Build the full SBATCH bash script that wraps the user's computation.
|
|
205
|
+
|
|
206
|
+
Container resolution (see _resolve_compute_container):
|
|
207
|
+
- Default: per-function SIF built at deploy time, identified by env
|
|
208
|
+
COMPUTE_SIF_PATH + COMPUTE_ENTRYPOINT + COMPUTE_SCRIPT_EXT
|
|
209
|
+
- Override: invocation `container` block can point at a shared SIF or
|
|
210
|
+
pull a Docker image at runtime
|
|
211
|
+
|
|
212
|
+
The user script string is base64-encoded into the SBATCH script and
|
|
213
|
+
written to /tmp/quapp_job.<ext> on the compute node, then executed
|
|
214
|
+
through Apptainer with the configured entrypoint.
|
|
215
|
+
"""
|
|
216
|
+
resources = self.hpc_config.get("resources", {})
|
|
217
|
+
container = self.hpc_config.get("container", {})
|
|
218
|
+
environment = self.hpc_config.get("environment", {})
|
|
219
|
+
input_s3_paths = self.hpc_config.get("input_s3_paths", [])
|
|
220
|
+
|
|
221
|
+
sif_path, entrypoint, script_ext = _resolve_compute_container(container)
|
|
222
|
+
script_file = f"/tmp/quapp_job.{script_ext}"
|
|
223
|
+
|
|
224
|
+
# Entrypoint may reference the canonical filename; rewrite to actual extension
|
|
225
|
+
entrypoint_resolved = entrypoint.replace("/tmp/quapp_job.py", script_file) \
|
|
226
|
+
.replace("/tmp/quapp_job.sh", script_file)
|
|
227
|
+
|
|
228
|
+
self.logger.info(
|
|
229
|
+
"Compute container resolved: sif=%s entrypoint=%s script_file=%s",
|
|
230
|
+
sif_path, entrypoint_resolved, script_file,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
lines = ["#!/bin/bash"]
|
|
234
|
+
|
|
235
|
+
# ── SBATCH directives ─────────────────────────────────────────────────
|
|
236
|
+
gpus = int(resources.get("gpus", 0))
|
|
237
|
+
memory_mb = (resources.get("memoryMb")
|
|
238
|
+
or resources.get("memory_mb")
|
|
239
|
+
or int(resources.get("memory_gb", 4)) * 1024)
|
|
240
|
+
lines += [
|
|
241
|
+
f"#SBATCH --account=quapp",
|
|
242
|
+
f"#SBATCH --partition={resources.get('partition', 'cpu')}",
|
|
243
|
+
f"#SBATCH --nodes={resources.get('nodes', 1)}",
|
|
244
|
+
f"#SBATCH --ntasks={resources.get('ntasks', 1)}",
|
|
245
|
+
f"#SBATCH --cpus-per-task={resources.get('cpus_per_task', 1)}",
|
|
246
|
+
f"#SBATCH --mem={memory_mb}M",
|
|
247
|
+
f"#SBATCH --time={resources.get('walltime') or resources.get('time_limit', '01:00:00')}",
|
|
248
|
+
]
|
|
249
|
+
if gpus > 0:
|
|
250
|
+
gpu_type = resources.get("gpu_type", "")
|
|
251
|
+
gres = f"gpu:{gpu_type}:{gpus}" if gpu_type else f"gpu:{gpus}"
|
|
252
|
+
lines.append(f"#SBATCH --gres={gres}")
|
|
253
|
+
lines.append("")
|
|
254
|
+
|
|
255
|
+
# ── Runtime setup ─────────────────────────────────────────────────────
|
|
256
|
+
lines += ["set -euo pipefail", "module load apptainer 2>/dev/null || true", ""]
|
|
257
|
+
|
|
258
|
+
# ── Download S3 input files ───────────────────────────────────────────
|
|
259
|
+
if input_s3_paths:
|
|
260
|
+
lines.append("mkdir -p /tmp/quapp_inputs")
|
|
261
|
+
for path in input_s3_paths:
|
|
262
|
+
lines.append(f"aws s3 cp {shlex.quote(path)} /tmp/quapp_inputs/")
|
|
263
|
+
lines.append("")
|
|
264
|
+
|
|
265
|
+
# ── Extra environment variables ───────────────────────────────────────
|
|
266
|
+
for k, v in environment.items():
|
|
267
|
+
lines.append(f"export {k}={shlex.quote(str(v))}")
|
|
268
|
+
if environment:
|
|
269
|
+
lines.append("")
|
|
270
|
+
|
|
271
|
+
# ── Write and run user script inside Apptainer ────────────────────────
|
|
272
|
+
b64 = base64.b64encode(user_script.encode()).decode()
|
|
273
|
+
exec_prefix = f"apptainer exec {shlex.quote(sif_path)}" \
|
|
274
|
+
if not sif_path.startswith("docker://") \
|
|
275
|
+
else f"apptainer exec {sif_path}"
|
|
276
|
+
lines += [
|
|
277
|
+
f"echo {shlex.quote(b64)} | base64 -d > {script_file}",
|
|
278
|
+
f"chmod +x {script_file}",
|
|
279
|
+
"",
|
|
280
|
+
f"{exec_prefix} {entrypoint_resolved} > /tmp/quapp_stdout.txt 2>/tmp/quapp_stderr.txt || true",
|
|
281
|
+
"echo $? > /tmp/quapp_exit_code.txt",
|
|
282
|
+
"",
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
# ── Collect output and upload to S3 ──────────────────────────────────
|
|
286
|
+
s3_uri = f"s3://{self.s3_bucket}/{self.job_uuid}/output.json"
|
|
287
|
+
lines += [
|
|
288
|
+
f"python3 -c {shlex.quote(_COLLECT_PY)}",
|
|
289
|
+
f"aws s3 cp /tmp/output.json {shlex.quote(s3_uri)}",
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
return "\n".join(lines)
|
|
293
|
+
|
|
294
|
+
# ── Helpers ───────────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
def _fetch_slurm_state(self, slurm_job_id: str) -> str:
|
|
297
|
+
resp = requests.get(
|
|
298
|
+
f"{self.slurm.base_url}/job/{slurm_job_id}",
|
|
299
|
+
headers=self.slurm.auth_headers(),
|
|
300
|
+
timeout=15,
|
|
301
|
+
)
|
|
302
|
+
resp.raise_for_status()
|
|
303
|
+
data = resp.json()
|
|
304
|
+
|
|
305
|
+
jobs = data.get("jobs", [data])
|
|
306
|
+
if not jobs:
|
|
307
|
+
return "UNKNOWN"
|
|
308
|
+
|
|
309
|
+
raw_state = jobs[0].get("job_state", "UNKNOWN")
|
|
310
|
+
if isinstance(raw_state, list):
|
|
311
|
+
raw_state = raw_state[0] if raw_state else "UNKNOWN"
|
|
312
|
+
|
|
313
|
+
return str(raw_state).strip()
|
|
314
|
+
|
|
315
|
+
def _download_s3_result(self, job_uuid: str) -> dict:
|
|
316
|
+
s3_key = f"{job_uuid}/output.json"
|
|
317
|
+
self.logger.info(f"Downloading result from s3://{self.s3_bucket}/{s3_key}")
|
|
318
|
+
|
|
319
|
+
s3 = boto3.client("s3", region_name=AWS_REGION)
|
|
320
|
+
resp = s3.get_object(Bucket=self.s3_bucket, Key=s3_key)
|
|
321
|
+
return json.loads(resp["Body"].read())
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import requests
|
|
3
|
+
from quapp_common.config.logging_config import job_logger
|
|
4
|
+
from quapp_common.enum.provider_tag import ProviderTag
|
|
5
|
+
from quapp_common.model.provider.provider import Provider
|
|
6
|
+
|
|
7
|
+
logger = job_logger('SlurmProvider')
|
|
8
|
+
|
|
9
|
+
SLURM_API_URL = os.getenv("SLURM_API_URL", "http://10.1.0.15:6820")
|
|
10
|
+
SLURM_API_VER = os.getenv("SLURM_API_VER", "v0.0.40")
|
|
11
|
+
SLURM_USERNAME = os.getenv("SLURM_USERNAME", "quapp-svc")
|
|
12
|
+
SLURM_ACCOUNT = os.getenv("SLURM_ACCOUNT", "quapp")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SlurmProvider(Provider):
|
|
16
|
+
|
|
17
|
+
def __init__(self, jwt_token: str):
|
|
18
|
+
super().__init__(ProviderTag.QUAPP_HPC)
|
|
19
|
+
self.jwt_token = jwt_token
|
|
20
|
+
self.base_url = f"{SLURM_API_URL}/slurm/{SLURM_API_VER}"
|
|
21
|
+
|
|
22
|
+
def get_backend(self, device_specification: str) -> str:
|
|
23
|
+
# device_specification maps to Slurm partition name
|
|
24
|
+
return device_specification or "compute"
|
|
25
|
+
|
|
26
|
+
def collect_provider(self):
|
|
27
|
+
resp = requests.get(
|
|
28
|
+
f"{SLURM_API_URL}/ping",
|
|
29
|
+
timeout=10,
|
|
30
|
+
)
|
|
31
|
+
resp.raise_for_status()
|
|
32
|
+
logger.debug(f"Slurm API reachable: {resp.json()}")
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
def auth_headers(self) -> dict:
|
|
36
|
+
return {
|
|
37
|
+
"X-SLURM-USER-NAME": SLURM_USERNAME,
|
|
38
|
+
"X-SLURM-USER-TOKEN": self.jwt_token,
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quapp-hpc
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Quapp HPC library — Slurm integration for Quapp Platform
|
|
5
|
+
Author-email: "CITYNOW Co. Ltd." <corp@citynow.vn>
|
|
6
|
+
License: The MIT License (MIT)
|
|
7
|
+
Copyright © CITYNOW Co. Ltd. All rights reserved.
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
14
|
+
Project-URL: Homepage, https://quapp.cloud/
|
|
15
|
+
Keywords: quapp,quapp-hpc,slurm,hpc
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Requires-Python: <3.13,>=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: quapp-common==0.0.13
|
|
23
|
+
Requires-Dist: requests>=2.31.0
|
|
24
|
+
Requires-Dist: boto3>=1.28.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: black; extra == "dev"
|
|
27
|
+
Requires-Dist: bumpver; extra == "dev"
|
|
28
|
+
Requires-Dist: isort; extra == "dev"
|
|
29
|
+
Requires-Dist: pip-tools; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# quapp-hpc
|
|
34
|
+
|
|
35
|
+
Python library cho Quapp HPC functions — cầu nối giữa Quapp FaaS platform và Slurm HPC cluster.
|
|
36
|
+
|
|
37
|
+
## Architecture
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
ksvc (Docker)
|
|
41
|
+
├── index.py FastAPI server
|
|
42
|
+
├── quapp_hpc/
|
|
43
|
+
│ ├── factory/
|
|
44
|
+
│ │ └── hpc_handler_factory.py Entry point cho user
|
|
45
|
+
│ ├── component/backend/
|
|
46
|
+
│ │ └── hpc_invocation.py Orchestrates job lifecycle
|
|
47
|
+
│ └── model/
|
|
48
|
+
│ ├── provider/slurm_provider.py Auth headers, base URL
|
|
49
|
+
│ └── device/slurm_device.py Submit → Poll → S3 download
|
|
50
|
+
└── function/
|
|
51
|
+
└── handler.py User-defined processing() + post_processing()
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Luồng thực thi
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
index.py nhận HTTP POST
|
|
58
|
+
→ HpcHandlerFactory.create_handler(event, processing_fn, post_processing_fn)
|
|
59
|
+
→ InvocationHandler.handle()
|
|
60
|
+
→ HpcInvocation.submit_job()
|
|
61
|
+
1. processing_fn(invocation_input) → bash script string
|
|
62
|
+
2. SlurmDevice._create_job(script) → POST Slurm REST API → slurm_job_id
|
|
63
|
+
3. SlurmDevice._get_job_result() → poll mỗi 30s → COMPLETED/FAILED
|
|
64
|
+
4. SlurmDevice._download_s3_result()→ boto3 get s3://$S3_BUCKET/$JOB_UUID/output.json
|
|
65
|
+
5. post_processing_fn(s3_result) → final response
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Environment variables (từ K8s Secret `slurm-credentials`)
|
|
69
|
+
|
|
70
|
+
| Var | Ví dụ | Mô tả |
|
|
71
|
+
|-----|-------|-------|
|
|
72
|
+
| `SLURM_API_URL` | `http://10.1.0.15:6820` | Slurm REST API base URL |
|
|
73
|
+
| `SLURM_JWT` | `eyJ...` | JWT token cho Slurm auth |
|
|
74
|
+
| `SLURM_USERNAME` | `quapp-svc` | Slurm username |
|
|
75
|
+
| `SLURM_ACCOUNT` | `quapp` | Slurm account/allocation |
|
|
76
|
+
| `S3_BUCKET` | `quapp-slurm-output-dev` | S3 bucket cho job output |
|
|
77
|
+
| `AWS_REGION` | `ap-southeast-1` | AWS region |
|
|
78
|
+
| `SLURM_POLL_SEC` | `30` | Polling interval (giây) |
|
|
79
|
+
| `SLURM_TIMEOUT_SEC` | `21600` | Max wait time (giây, default 6h) |
|
|
80
|
+
|
|
81
|
+
## invocation_input schema
|
|
82
|
+
|
|
83
|
+
Xem chi tiết tại [`../qapp-sdk-templates/slurm-hpc/README.md`](../../qapp-sdk-templates/slurm-hpc/README.md).
|
|
84
|
+
|
|
85
|
+
Tóm tắt:
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"resources": { "partition", "nodes", "cpus_per_task", "gpus", "memory_gb", "time_limit" },
|
|
89
|
+
"container": { "type": "sif"|"docker"|"none", "image": "..." },
|
|
90
|
+
"job": { "type": "script"|"command", "script"|"command": "...", "environment": {}, "input_s3_paths": [] }
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Slurm REST API
|
|
95
|
+
|
|
96
|
+
- Version: `v0.0.40`
|
|
97
|
+
- Submit: `POST {SLURM_API_URL}/slurm/v0.0.40/job/submit`
|
|
98
|
+
- Status: `GET {SLURM_API_URL}/slurm/v0.0.40/job/{job_id}`
|
|
99
|
+
- Auth headers: `X-SLURM-USER-NAME`, `X-SLURM-USER-TOKEN`
|
|
100
|
+
|
|
101
|
+
### Job state mapping
|
|
102
|
+
|
|
103
|
+
| Slurm state | Quapp state |
|
|
104
|
+
|---|---|
|
|
105
|
+
| PENDING, CONFIGURING, RUNNING, COMPLETING | RUNNING |
|
|
106
|
+
| COMPLETED | DONE |
|
|
107
|
+
| FAILED, CANCELLED, TIMEOUT, NODE_FAIL, PREEMPTED | ERROR |
|
|
108
|
+
|
|
109
|
+
## S3 output pattern
|
|
110
|
+
|
|
111
|
+
Job script phải upload kết quả:
|
|
112
|
+
```bash
|
|
113
|
+
aws s3 cp /tmp/output.json s3://$S3_BUCKET/$JOB_UUID/output.json
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`$JOB_UUID` và `$S3_BUCKET` được inject tự động bởi `SlurmDevice._create_job()` qua Slurm `environment` array.
|
|
117
|
+
|
|
118
|
+
## K8s Secret
|
|
119
|
+
|
|
120
|
+
```yaml
|
|
121
|
+
# infrastructure/quapp-job-scheduler/k8s/cts/slurm-secret.yaml
|
|
122
|
+
apiVersion: v1
|
|
123
|
+
kind: Secret
|
|
124
|
+
metadata:
|
|
125
|
+
name: slurm-credentials
|
|
126
|
+
namespace: quapp-functions-dev
|
|
127
|
+
stringData:
|
|
128
|
+
SLURM_JWT: "<generate: sudo scontrol token username=quapp-svc lifespan=2592000>"
|
|
129
|
+
SLURM_API_URL: "http://10.1.0.15:6820"
|
|
130
|
+
SLURM_USERNAME: "quapp-svc"
|
|
131
|
+
SLURM_ACCOUNT: "quapp"
|
|
132
|
+
S3_BUCKET: "quapp-slurm-output-dev"
|
|
133
|
+
AWS_REGION: "ap-southeast-1"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## DB seed required
|
|
137
|
+
|
|
138
|
+
Chạy script trước khi deploy:
|
|
139
|
+
```
|
|
140
|
+
infrastructure/quapp-functions-backend/docs/db/seed_slurm_hpc.sql
|
|
141
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
quapp_hpc/__init__.py
|
|
5
|
+
quapp_hpc.egg-info/PKG-INFO
|
|
6
|
+
quapp_hpc.egg-info/SOURCES.txt
|
|
7
|
+
quapp_hpc.egg-info/dependency_links.txt
|
|
8
|
+
quapp_hpc.egg-info/requires.txt
|
|
9
|
+
quapp_hpc.egg-info/top_level.txt
|
|
10
|
+
quapp_hpc/component/__init__.py
|
|
11
|
+
quapp_hpc/component/backend/__init__.py
|
|
12
|
+
quapp_hpc/component/backend/hpc_invocation.py
|
|
13
|
+
quapp_hpc/component/backend/slurm_job_fetching.py
|
|
14
|
+
quapp_hpc/factory/__init__.py
|
|
15
|
+
quapp_hpc/factory/hpc_device_factory.py
|
|
16
|
+
quapp_hpc/factory/hpc_handler_factory.py
|
|
17
|
+
quapp_hpc/factory/hpc_provider_factory.py
|
|
18
|
+
quapp_hpc/handler/__init__.py
|
|
19
|
+
quapp_hpc/handler/invocation_handler.py
|
|
20
|
+
quapp_hpc/handler/job_fetching_handler.py
|
|
21
|
+
quapp_hpc/model/__init__.py
|
|
22
|
+
quapp_hpc/model/device/__init__.py
|
|
23
|
+
quapp_hpc/model/device/slurm_device.py
|
|
24
|
+
quapp_hpc/model/provider/__init__.py
|
|
25
|
+
quapp_hpc/model/provider/slurm_provider.py
|
|
26
|
+
quapp_slurm_hpc/__init__.py
|
|
27
|
+
quapp_slurm_hpc/runner.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
File without changes
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""CLI runner — invoked by the sbatch script inside the Apptainer container.
|
|
2
|
+
|
|
3
|
+
The Java SlurmPlatformManager generates a sbatch script that runs:
|
|
4
|
+
|
|
5
|
+
apptainer exec docker://<image> python -m quapp_slurm_hpc.runner \
|
|
6
|
+
--input-file /data/jobs/<jobId>/input.json \
|
|
7
|
+
--output-file /data/jobs/<jobId>/output.json \
|
|
8
|
+
--handler-dir /home/app/function
|
|
9
|
+
|
|
10
|
+
This module:
|
|
11
|
+
1. Reads the invocation input from --input-file (JSON written by the sbatch script).
|
|
12
|
+
2. Loads handler.py from --handler-dir.
|
|
13
|
+
3. Calls handler.processing(invocation_input) → bash script string.
|
|
14
|
+
4. Executes the bash script, captures stdout / stderr / exit_code.
|
|
15
|
+
5. Calls handler.post_processing(raw_result) if it exists.
|
|
16
|
+
6. Writes the final result dict to --output-file.
|
|
17
|
+
7. Uploads output.json to S3 (key: {QUAPP_JOB_ID}/output.json) when SLURM_S3_BUCKET is set.
|
|
18
|
+
"""
|
|
19
|
+
import argparse
|
|
20
|
+
import importlib.util
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
import traceback
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from types import ModuleType
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_handler(handler_dir: str) -> ModuleType:
|
|
32
|
+
handler_path = Path(handler_dir) / "handler.py"
|
|
33
|
+
if not handler_path.exists():
|
|
34
|
+
raise FileNotFoundError(
|
|
35
|
+
f"handler.py not found in {handler_dir!r}. "
|
|
36
|
+
"Ensure the function image was built with the handler in /home/app/function."
|
|
37
|
+
)
|
|
38
|
+
if str(handler_dir) not in sys.path:
|
|
39
|
+
sys.path.insert(0, str(handler_dir))
|
|
40
|
+
spec = importlib.util.spec_from_file_location("handler", handler_path)
|
|
41
|
+
module = importlib.util.module_from_spec(spec)
|
|
42
|
+
spec.loader.exec_module(module)
|
|
43
|
+
return module
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _run_script(script: str) -> dict:
|
|
47
|
+
"""Write script to a temp file and execute it with bash, capturing output."""
|
|
48
|
+
with tempfile.NamedTemporaryFile(
|
|
49
|
+
mode="w", suffix=".sh", delete=False, encoding="utf-8"
|
|
50
|
+
) as f:
|
|
51
|
+
f.write(script)
|
|
52
|
+
tmp_path = f.name
|
|
53
|
+
try:
|
|
54
|
+
result = subprocess.run(
|
|
55
|
+
["bash", tmp_path],
|
|
56
|
+
capture_output=True,
|
|
57
|
+
text=True,
|
|
58
|
+
)
|
|
59
|
+
return {
|
|
60
|
+
"exit_code": result.returncode,
|
|
61
|
+
"stdout": result.stdout,
|
|
62
|
+
"stderr": result.stderr,
|
|
63
|
+
}
|
|
64
|
+
finally:
|
|
65
|
+
Path(tmp_path).unlink(missing_ok=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main() -> None:
|
|
69
|
+
parser = argparse.ArgumentParser(
|
|
70
|
+
description="Quapp HPC runner — executes handler.py inside an Apptainer container"
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument("--input-file", required=True, help="Path to input JSON file")
|
|
73
|
+
parser.add_argument("--output-file", required=True, help="Path to write output JSON")
|
|
74
|
+
parser.add_argument("--handler-dir", required=True, help="Directory containing handler.py")
|
|
75
|
+
args = parser.parse_args()
|
|
76
|
+
|
|
77
|
+
output: dict = {}
|
|
78
|
+
try:
|
|
79
|
+
with open(args.input_file, encoding="utf-8") as f:
|
|
80
|
+
invocation_input = json.load(f)
|
|
81
|
+
|
|
82
|
+
handler = _load_handler(args.handler_dir)
|
|
83
|
+
|
|
84
|
+
if not hasattr(handler, "processing"):
|
|
85
|
+
raise AttributeError(
|
|
86
|
+
"handler.py must define a processing(invocation_input: dict) -> str function"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
script = handler.processing(invocation_input)
|
|
90
|
+
if not isinstance(script, str):
|
|
91
|
+
raise TypeError(
|
|
92
|
+
f"handler.processing() must return a str (bash script), got {type(script).__name__}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
raw_result = _run_script(script)
|
|
96
|
+
|
|
97
|
+
if hasattr(handler, "post_processing"):
|
|
98
|
+
output = handler.post_processing(raw_result)
|
|
99
|
+
else:
|
|
100
|
+
output = raw_result
|
|
101
|
+
|
|
102
|
+
except Exception:
|
|
103
|
+
output = {
|
|
104
|
+
"exit_code": 1,
|
|
105
|
+
"stdout": "",
|
|
106
|
+
"stderr": traceback.format_exc(),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
with open(args.output_file, "w", encoding="utf-8") as f:
|
|
110
|
+
json.dump(output, f)
|
|
111
|
+
|
|
112
|
+
_upload_to_s3(args.output_file)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _upload_to_s3(output_file: str) -> None:
|
|
116
|
+
"""Upload output.json to S3 so the Java backend can read the result.
|
|
117
|
+
|
|
118
|
+
Requires SLURM_S3_BUCKET and QUAPP_JOB_ID env vars (injected by sbatch script).
|
|
119
|
+
Skips silently when the bucket is not configured.
|
|
120
|
+
"""
|
|
121
|
+
s3_bucket = os.getenv("SLURM_S3_BUCKET", "")
|
|
122
|
+
job_uuid = os.getenv("QUAPP_JOB_ID", "")
|
|
123
|
+
if not s3_bucket or not job_uuid:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
import boto3
|
|
128
|
+
s3 = boto3.client(
|
|
129
|
+
"s3",
|
|
130
|
+
region_name=os.getenv("SLURM_S3_REGION", "ap-southeast-1"),
|
|
131
|
+
aws_access_key_id=os.getenv("SLURM_S3_ACCESS_KEY"),
|
|
132
|
+
aws_secret_access_key=os.getenv("SLURM_S3_SECRET_KEY"),
|
|
133
|
+
)
|
|
134
|
+
s3_key = f"{job_uuid}/output.json"
|
|
135
|
+
s3.upload_file(output_file, s3_bucket, s3_key)
|
|
136
|
+
print(f"[runner] Uploaded output to s3://{s3_bucket}/{s3_key}", flush=True)
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
print(f"[runner] WARNING: S3 upload failed: {exc}", file=sys.stderr, flush=True)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
main()
|