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.
Files changed (29) hide show
  1. quapp_hpc-0.0.1/LICENSE +8 -0
  2. quapp_hpc-0.0.1/PKG-INFO +141 -0
  3. quapp_hpc-0.0.1/README.md +109 -0
  4. quapp_hpc-0.0.1/pyproject.toml +33 -0
  5. quapp_hpc-0.0.1/quapp_hpc/__init__.py +3 -0
  6. quapp_hpc-0.0.1/quapp_hpc/component/__init__.py +0 -0
  7. quapp_hpc-0.0.1/quapp_hpc/component/backend/__init__.py +0 -0
  8. quapp_hpc-0.0.1/quapp_hpc/component/backend/hpc_invocation.py +43 -0
  9. quapp_hpc-0.0.1/quapp_hpc/component/backend/slurm_job_fetching.py +95 -0
  10. quapp_hpc-0.0.1/quapp_hpc/factory/__init__.py +0 -0
  11. quapp_hpc-0.0.1/quapp_hpc/factory/hpc_device_factory.py +19 -0
  12. quapp_hpc-0.0.1/quapp_hpc/factory/hpc_handler_factory.py +30 -0
  13. quapp_hpc-0.0.1/quapp_hpc/factory/hpc_provider_factory.py +24 -0
  14. quapp_hpc-0.0.1/quapp_hpc/handler/__init__.py +0 -0
  15. quapp_hpc-0.0.1/quapp_hpc/handler/invocation_handler.py +23 -0
  16. quapp_hpc-0.0.1/quapp_hpc/handler/job_fetching_handler.py +18 -0
  17. quapp_hpc-0.0.1/quapp_hpc/model/__init__.py +0 -0
  18. quapp_hpc-0.0.1/quapp_hpc/model/device/__init__.py +0 -0
  19. quapp_hpc-0.0.1/quapp_hpc/model/device/slurm_device.py +321 -0
  20. quapp_hpc-0.0.1/quapp_hpc/model/provider/__init__.py +0 -0
  21. quapp_hpc-0.0.1/quapp_hpc/model/provider/slurm_provider.py +40 -0
  22. quapp_hpc-0.0.1/quapp_hpc.egg-info/PKG-INFO +141 -0
  23. quapp_hpc-0.0.1/quapp_hpc.egg-info/SOURCES.txt +27 -0
  24. quapp_hpc-0.0.1/quapp_hpc.egg-info/dependency_links.txt +1 -0
  25. quapp_hpc-0.0.1/quapp_hpc.egg-info/requires.txt +10 -0
  26. quapp_hpc-0.0.1/quapp_hpc.egg-info/top_level.txt +2 -0
  27. quapp_hpc-0.0.1/quapp_slurm_hpc/__init__.py +0 -0
  28. quapp_hpc-0.0.1/quapp_slurm_hpc/runner.py +142 -0
  29. quapp_hpc-0.0.1/setup.cfg +4 -0
@@ -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.
@@ -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/"
@@ -0,0 +1,3 @@
1
+ from .factory.hpc_handler_factory import HpcHandlerFactory
2
+
3
+ __all__ = ["HpcHandlerFactory"]
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,10 @@
1
+ quapp-common==0.0.13
2
+ requests>=2.31.0
3
+ boto3>=1.28.0
4
+
5
+ [dev]
6
+ black
7
+ bumpver
8
+ isort
9
+ pip-tools
10
+ pytest
@@ -0,0 +1,2 @@
1
+ quapp_hpc
2
+ quapp_slurm_hpc
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()
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+