ls-algorithm-plugin-sdk 0.2.5__tar.gz → 0.3.0__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.
- ls_algorithm_plugin_sdk-0.3.0/PKG-INFO +100 -0
- ls_algorithm_plugin_sdk-0.3.0/README.md +85 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/pyproject.toml +1 -1
- ls_algorithm_plugin_sdk-0.3.0/src/algorithm_plugin_sdk/cli.py +44 -0
- ls_algorithm_plugin_sdk-0.3.0/src/algorithm_plugin_sdk/cli_impl/__init__.py +1 -0
- ls_algorithm_plugin_sdk-0.3.0/src/algorithm_plugin_sdk/cli_impl/configure.py +421 -0
- ls_algorithm_plugin_sdk-0.3.0/src/algorithm_plugin_sdk/cli_impl/parsing.py +45 -0
- ls_algorithm_plugin_sdk-0.3.0/src/algorithm_plugin_sdk/cli_impl/run.py +164 -0
- ls_algorithm_plugin_sdk-0.3.0/src/algorithm_plugin_sdk/cli_impl/serve.py +130 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/deployment.py +50 -16
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/examples/example_algorithm.py +2 -2
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/examples/simulated_algorithm.py +1 -1
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/models.py +78 -99
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/release.py +81 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/runner.py +23 -45
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/service.py +1 -2
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/webui/index.html +1 -1
- ls_algorithm_plugin_sdk-0.3.0/src/ls_algorithm_plugin_sdk.egg-info/PKG-INFO +100 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/ls_algorithm_plugin_sdk.egg-info/SOURCES.txt +7 -0
- ls_algorithm_plugin_sdk-0.3.0/tests/test_cli.py +53 -0
- ls_algorithm_plugin_sdk-0.3.0/tests/test_configure.py +532 -0
- ls_algorithm_plugin_sdk-0.3.0/tests/test_deployment.py +185 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/tests/test_models.py +28 -14
- ls_algorithm_plugin_sdk-0.3.0/tests/test_release.py +174 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/tests/test_runner.py +81 -13
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/tests/test_service.py +3 -3
- ls_algorithm_plugin_sdk-0.2.5/PKG-INFO +0 -87
- ls_algorithm_plugin_sdk-0.2.5/README.md +0 -72
- ls_algorithm_plugin_sdk-0.2.5/src/algorithm_plugin_sdk/cli.py +0 -542
- ls_algorithm_plugin_sdk-0.2.5/src/ls_algorithm_plugin_sdk.egg-info/PKG-INFO +0 -87
- ls_algorithm_plugin_sdk-0.2.5/tests/test_deployment.py +0 -90
- ls_algorithm_plugin_sdk-0.2.5/tests/test_release.py +0 -60
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/setup.cfg +0 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/__init__.py +0 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/algorithm.py +0 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/context.py +0 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/errors.py +0 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/examples/__init__.py +0 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/loader.py +0 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/registration.py +0 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/webui/__init__.py +0 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/webui/app.css +0 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/webui/app.js +0 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/algorithm_plugin_sdk/webui_app.py +0 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/ls_algorithm_plugin_sdk.egg-info/dependency_links.txt +0 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/ls_algorithm_plugin_sdk.egg-info/entry_points.txt +0 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/ls_algorithm_plugin_sdk.egg-info/requires.txt +0 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/src/ls_algorithm_plugin_sdk.egg-info/top_level.txt +0 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/tests/test_registration.py +0 -0
- {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.0}/tests/test_sdk_automation.py +0 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ls-algorithm-plugin-sdk
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Protocol-independent runtime SDK for dataset algorithms
|
|
5
|
+
Author: Ling Robotics
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Provides-Extra: service
|
|
10
|
+
Requires-Dist: fastapi<1,>=0.110; extra == "service"
|
|
11
|
+
Requires-Dist: pydantic<3,>=2.0; extra == "service"
|
|
12
|
+
Requires-Dist: uvicorn[standard]<1,>=0.29; extra == "service"
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# Algorithm Plugin SDK
|
|
17
|
+
|
|
18
|
+
算法仓库只实现一个 `Algorithm` 类并声明 entry point。SDK 统一提供单次运行、
|
|
19
|
+
WebUI、HTTP service、release manifest、compute 注册和心跳。算法仓库不需要
|
|
20
|
+
Dockerfile 或 Web 框架代码。
|
|
21
|
+
|
|
22
|
+
## 算法侧最小接口
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from algorithm_plugin_sdk import Algorithm
|
|
26
|
+
|
|
27
|
+
class MyAlgorithm(Algorithm):
|
|
28
|
+
@classmethod
|
|
29
|
+
def metadata(cls): ...
|
|
30
|
+
def execute(self, request, context): ...
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
算法包需要声明 `algorithm_plugin_sdk.algorithms` entry point。同一个 `execute()`
|
|
34
|
+
会被所有运行方式复用。
|
|
35
|
+
|
|
36
|
+
## 单次运行
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
algorithm-plugin run camera-space-mano \
|
|
40
|
+
--input '{"input_dataset":"/data/input1","output":"/data/output1"}' \
|
|
41
|
+
--input '{"input_dataset":"/data/input2","output":"/data/output2"}' \
|
|
42
|
+
--gpu-ids 0 \
|
|
43
|
+
--parameters '{"batch_size":16}'
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`--gpu-ids` 和 `--parameters` 可省略,也可用 `--request-file` 读取完整请求 JSON。
|
|
47
|
+
|
|
48
|
+
## 配置现有仓库
|
|
49
|
+
|
|
50
|
+
目标算法仓库必须满足:
|
|
51
|
+
|
|
52
|
+
- 仓库本身是 Git 工作树根目录,并且至少有一个 commit。
|
|
53
|
+
- 仓库包含自己的 `.venv`。
|
|
54
|
+
- `<repository>/.venv/bin/algorithm-plugin` 存在且可执行。
|
|
55
|
+
- 该启动器的 `run --help` 和 `serve --help` 均可正常加载。
|
|
56
|
+
|
|
57
|
+
建议将生成的 `release-manifest.json` 和 `algorithm-plugin.json` 加入算法仓库的
|
|
58
|
+
`.gitignore`,不要手动编辑或提交它们。
|
|
59
|
+
|
|
60
|
+
通过目标仓库的虚拟环境执行:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
sudo /srv/camera-space-mano/.venv/bin/algorithm-plugin configure \
|
|
64
|
+
--repository /srv/camera-space-mano \
|
|
65
|
+
--compute-url http://compute:5180 \
|
|
66
|
+
--api-key "$LDP_INTERNAL_API_KEY" \
|
|
67
|
+
--public-url http://10.0.0.4:9000 \
|
|
68
|
+
--input-root /data/jobs/input \
|
|
69
|
+
--workspace-root /data/jobs/workspace \
|
|
70
|
+
--cluster production \
|
|
71
|
+
--namespace camera-space-mano \
|
|
72
|
+
--node-name 10.0.0.4 \
|
|
73
|
+
--gpu-ids 0,1
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
service name 默认使用仓库目录名,也可以通过 `--service-name` 指定。
|
|
77
|
+
重复执行 configure 需要显式提供 `--force`。
|
|
78
|
+
|
|
79
|
+
确认输出后执行:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
sudo systemctl daemon-reload
|
|
83
|
+
sudo systemctl enable camera-space-mano.service
|
|
84
|
+
sudo systemctl restart camera-space-mano.service
|
|
85
|
+
sudo systemctl status camera-space-mano.service
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## serve
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
/srv/camera-space-mano/.venv/bin/algorithm-plugin serve \
|
|
92
|
+
--config /srv/camera-space-mano/algorithm-plugin.json
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## 安装与测试
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
python -m pip install '.[service]'
|
|
99
|
+
python -m unittest discover -s tests -v
|
|
100
|
+
```
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Algorithm Plugin SDK
|
|
2
|
+
|
|
3
|
+
算法仓库只实现一个 `Algorithm` 类并声明 entry point。SDK 统一提供单次运行、
|
|
4
|
+
WebUI、HTTP service、release manifest、compute 注册和心跳。算法仓库不需要
|
|
5
|
+
Dockerfile 或 Web 框架代码。
|
|
6
|
+
|
|
7
|
+
## 算法侧最小接口
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from algorithm_plugin_sdk import Algorithm
|
|
11
|
+
|
|
12
|
+
class MyAlgorithm(Algorithm):
|
|
13
|
+
@classmethod
|
|
14
|
+
def metadata(cls): ...
|
|
15
|
+
def execute(self, request, context): ...
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
算法包需要声明 `algorithm_plugin_sdk.algorithms` entry point。同一个 `execute()`
|
|
19
|
+
会被所有运行方式复用。
|
|
20
|
+
|
|
21
|
+
## 单次运行
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
algorithm-plugin run camera-space-mano \
|
|
25
|
+
--input '{"input_dataset":"/data/input1","output":"/data/output1"}' \
|
|
26
|
+
--input '{"input_dataset":"/data/input2","output":"/data/output2"}' \
|
|
27
|
+
--gpu-ids 0 \
|
|
28
|
+
--parameters '{"batch_size":16}'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`--gpu-ids` 和 `--parameters` 可省略,也可用 `--request-file` 读取完整请求 JSON。
|
|
32
|
+
|
|
33
|
+
## 配置现有仓库
|
|
34
|
+
|
|
35
|
+
目标算法仓库必须满足:
|
|
36
|
+
|
|
37
|
+
- 仓库本身是 Git 工作树根目录,并且至少有一个 commit。
|
|
38
|
+
- 仓库包含自己的 `.venv`。
|
|
39
|
+
- `<repository>/.venv/bin/algorithm-plugin` 存在且可执行。
|
|
40
|
+
- 该启动器的 `run --help` 和 `serve --help` 均可正常加载。
|
|
41
|
+
|
|
42
|
+
建议将生成的 `release-manifest.json` 和 `algorithm-plugin.json` 加入算法仓库的
|
|
43
|
+
`.gitignore`,不要手动编辑或提交它们。
|
|
44
|
+
|
|
45
|
+
通过目标仓库的虚拟环境执行:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
sudo /srv/camera-space-mano/.venv/bin/algorithm-plugin configure \
|
|
49
|
+
--repository /srv/camera-space-mano \
|
|
50
|
+
--compute-url http://compute:5180 \
|
|
51
|
+
--api-key "$LDP_INTERNAL_API_KEY" \
|
|
52
|
+
--public-url http://10.0.0.4:9000 \
|
|
53
|
+
--input-root /data/jobs/input \
|
|
54
|
+
--workspace-root /data/jobs/workspace \
|
|
55
|
+
--cluster production \
|
|
56
|
+
--namespace camera-space-mano \
|
|
57
|
+
--node-name 10.0.0.4 \
|
|
58
|
+
--gpu-ids 0,1
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
service name 默认使用仓库目录名,也可以通过 `--service-name` 指定。
|
|
62
|
+
重复执行 configure 需要显式提供 `--force`。
|
|
63
|
+
|
|
64
|
+
确认输出后执行:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
sudo systemctl daemon-reload
|
|
68
|
+
sudo systemctl enable camera-space-mano.service
|
|
69
|
+
sudo systemctl restart camera-space-mano.service
|
|
70
|
+
sudo systemctl status camera-space-mano.service
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## serve
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
/srv/camera-space-mano/.venv/bin/algorithm-plugin serve \
|
|
77
|
+
--config /srv/camera-space-mano/algorithm-plugin.json
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## 安装与测试
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
python -m pip install '.[service]'
|
|
84
|
+
python -m unittest discover -s tests -v
|
|
85
|
+
```
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .cli_impl import configure as configure_command
|
|
8
|
+
from .cli_impl import run as run_command
|
|
9
|
+
from .cli_impl import serve as serve_command
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
parser = argparse.ArgumentParser(prog="algorithm-plugin")
|
|
14
|
+
subs = parser.add_subparsers(dest="command", required=True)
|
|
15
|
+
|
|
16
|
+
run_command.configure_parser(
|
|
17
|
+
subs.add_parser("run", help="run datasets once")
|
|
18
|
+
)
|
|
19
|
+
serve_command.configure_parser(
|
|
20
|
+
subs.add_parser("serve", help="run the generated HTTP service")
|
|
21
|
+
)
|
|
22
|
+
configure_command.configure_parser(
|
|
23
|
+
subs.add_parser("configure", help="configure an existing Algorithm repository for systemd")
|
|
24
|
+
)
|
|
25
|
+
return parser
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main(argv: list[str] | None = None) -> int:
|
|
29
|
+
logging.basicConfig(
|
|
30
|
+
level=logging.INFO,
|
|
31
|
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
32
|
+
)
|
|
33
|
+
args = build_parser().parse_args(argv)
|
|
34
|
+
try:
|
|
35
|
+
return args.handler(args)
|
|
36
|
+
except KeyboardInterrupt:
|
|
37
|
+
return 130
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
40
|
+
return 2
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Implementation modules for the algorithm-plugin command line interface."""
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ..deployment import (
|
|
11
|
+
DEFAULT_CONFIG_NAME,
|
|
12
|
+
DeploymentConfig,
|
|
13
|
+
repository_root,
|
|
14
|
+
stable_instance_key,
|
|
15
|
+
)
|
|
16
|
+
from .parsing import environment, gpu_ids
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
SYSTEMD_UNIT_DIR = Path("/etc/systemd/system")
|
|
20
|
+
|
|
21
|
+
SERVICE_NAME_PATTERN = re.compile(
|
|
22
|
+
r"^[A-Za-z0-9][A-Za-z0-9_.@-]*$"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def configure_parser(parser: argparse.ArgumentParser) -> None:
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"algorithm",
|
|
29
|
+
nargs="?",
|
|
30
|
+
help="entry-point name or package.module:AlgorithmClass",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--repository",
|
|
34
|
+
type=Path,
|
|
35
|
+
default=Path.cwd(),
|
|
36
|
+
help="existing Algorithm repository containing its own .venv",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--config-output",
|
|
40
|
+
type=Path,
|
|
41
|
+
default=Path(DEFAULT_CONFIG_NAME),
|
|
42
|
+
help=f"config path relative to the repository (default: {DEFAULT_CONFIG_NAME})",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument("--host", default="0.0.0.0")
|
|
45
|
+
parser.add_argument("--port", type=int, default=9000)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--webui",
|
|
48
|
+
action=argparse.BooleanOptionalAction,
|
|
49
|
+
default=True,
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument("--max-concurrency", type=int, default=1)
|
|
52
|
+
parser.add_argument("--scratch-dir")
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--gpu-ids",
|
|
55
|
+
type=gpu_ids,
|
|
56
|
+
default=[],
|
|
57
|
+
help="GPU IDs injected into every service execution",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument("--service-token")
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--compute-url",
|
|
62
|
+
default=os.getenv("LDP_COMPUTE_URL"),
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"--api-key",
|
|
66
|
+
default=os.getenv("LDP_INTERNAL_API_KEY"),
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--public-url",
|
|
70
|
+
default=os.getenv("LDP_PLUGIN_PUBLIC_URL"),
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--input-root",
|
|
74
|
+
default=os.getenv("LDP_JOB_INPUT_ROOT"),
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--workspace-root",
|
|
78
|
+
default=os.getenv("LDP_JOB_WORKSPACE_ROOT"),
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--instance-key",
|
|
82
|
+
default=os.getenv("LDP_PLUGIN_INSTANCE_KEY"),
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--cluster",
|
|
86
|
+
default=os.getenv("LDP_CLUSTER"),
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--namespace",
|
|
90
|
+
default=os.getenv("POD_NAMESPACE"),
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"--node-name",
|
|
94
|
+
default=os.getenv("NODE_NAME"),
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--local",
|
|
98
|
+
action="store_true",
|
|
99
|
+
help="configure a local service without compute registration",
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
"--env",
|
|
103
|
+
action="append",
|
|
104
|
+
type=environment,
|
|
105
|
+
default=[],
|
|
106
|
+
metavar="NAME=VALUE",
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--service-name",
|
|
110
|
+
help="systemd unit name without .service; defaults to repository name",
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument(
|
|
113
|
+
"--force",
|
|
114
|
+
action="store_true",
|
|
115
|
+
help="replace an existing systemd unit whose content differs",
|
|
116
|
+
)
|
|
117
|
+
parser.set_defaults(handler=execute)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def resolve_service_name(
|
|
121
|
+
repository: Path,
|
|
122
|
+
requested: str | None,
|
|
123
|
+
) -> str:
|
|
124
|
+
name = requested or repository.name
|
|
125
|
+
if name.endswith(".service"):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
"service name must not include the .service suffix"
|
|
128
|
+
)
|
|
129
|
+
if not SERVICE_NAME_PATTERN.fullmatch(name):
|
|
130
|
+
raise ValueError(
|
|
131
|
+
"service name may contain only letters, numbers, "
|
|
132
|
+
"underscore, dot, @, and hyphen"
|
|
133
|
+
)
|
|
134
|
+
return name
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _quote_systemd_path(value: Path) -> str:
|
|
138
|
+
text = str(value)
|
|
139
|
+
if "\n" in text or "\r" in text or "\0" in text:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"systemd path contains control characters: {value}"
|
|
142
|
+
)
|
|
143
|
+
escaped = (
|
|
144
|
+
text.replace("\\", "\\\\")
|
|
145
|
+
.replace('"', '\\"')
|
|
146
|
+
.replace("%", "%%")
|
|
147
|
+
)
|
|
148
|
+
return f'"{escaped}"'
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def render_systemd_unit(
|
|
152
|
+
*,
|
|
153
|
+
repository: Path,
|
|
154
|
+
config_path: Path,
|
|
155
|
+
service_name: str,
|
|
156
|
+
) -> str:
|
|
157
|
+
root = repository.resolve()
|
|
158
|
+
launcher = root / ".venv" / "bin" / "algorithm-plugin"
|
|
159
|
+
config = config_path.resolve()
|
|
160
|
+
return "\n".join(
|
|
161
|
+
(
|
|
162
|
+
"[Unit]",
|
|
163
|
+
f"Description=Algorithm Plugin {service_name}",
|
|
164
|
+
"Wants=network-online.target",
|
|
165
|
+
"After=network-online.target",
|
|
166
|
+
"",
|
|
167
|
+
"[Service]",
|
|
168
|
+
"Type=simple",
|
|
169
|
+
"User=root",
|
|
170
|
+
f"WorkingDirectory={_quote_systemd_path(root)}",
|
|
171
|
+
(
|
|
172
|
+
f"ExecStart={_quote_systemd_path(launcher)} "
|
|
173
|
+
f"serve --config {_quote_systemd_path(config)}"
|
|
174
|
+
),
|
|
175
|
+
"Environment=PYTHONUNBUFFERED=1",
|
|
176
|
+
"Restart=on-failure",
|
|
177
|
+
"RestartSec=5s",
|
|
178
|
+
"",
|
|
179
|
+
"[Install]",
|
|
180
|
+
"WantedBy=multi-user.target",
|
|
181
|
+
"",
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def require_root() -> None:
|
|
187
|
+
if os.geteuid() != 0:
|
|
188
|
+
raise PermissionError(
|
|
189
|
+
"configure must run as root to write systemd units"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def inspect_systemd_unit(
|
|
194
|
+
*,
|
|
195
|
+
service_name: str,
|
|
196
|
+
content: str,
|
|
197
|
+
force: bool,
|
|
198
|
+
unit_dir: Path = SYSTEMD_UNIT_DIR,
|
|
199
|
+
) -> tuple[Path, bool]:
|
|
200
|
+
require_root()
|
|
201
|
+
if not unit_dir.is_dir():
|
|
202
|
+
raise NotADirectoryError(
|
|
203
|
+
f"systemd unit directory is unavailable: {unit_dir}"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
target = unit_dir / f"{service_name}.service"
|
|
207
|
+
if target.is_file():
|
|
208
|
+
existing = target.read_text(encoding="utf-8")
|
|
209
|
+
if existing == content:
|
|
210
|
+
return target, False
|
|
211
|
+
if not force:
|
|
212
|
+
raise FileExistsError(
|
|
213
|
+
f"systemd unit differs: {target}; "
|
|
214
|
+
"use --force to replace it"
|
|
215
|
+
)
|
|
216
|
+
elif target.exists():
|
|
217
|
+
raise FileExistsError(
|
|
218
|
+
f"systemd unit path is not a regular file: {target}"
|
|
219
|
+
)
|
|
220
|
+
return target, True
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def write_systemd_unit(
|
|
224
|
+
*,
|
|
225
|
+
service_name: str,
|
|
226
|
+
content: str,
|
|
227
|
+
force: bool,
|
|
228
|
+
unit_dir: Path = SYSTEMD_UNIT_DIR,
|
|
229
|
+
) -> tuple[Path, bool]:
|
|
230
|
+
target, changed = inspect_systemd_unit(
|
|
231
|
+
service_name=service_name,
|
|
232
|
+
content=content,
|
|
233
|
+
force=force,
|
|
234
|
+
unit_dir=unit_dir,
|
|
235
|
+
)
|
|
236
|
+
if not changed:
|
|
237
|
+
return target, False
|
|
238
|
+
|
|
239
|
+
temporary = target.with_name(
|
|
240
|
+
f".{target.name}.{os.getpid()}.tmp"
|
|
241
|
+
)
|
|
242
|
+
try:
|
|
243
|
+
temporary.write_text(content, encoding="utf-8")
|
|
244
|
+
temporary.chmod(0o644)
|
|
245
|
+
temporary.replace(target)
|
|
246
|
+
finally:
|
|
247
|
+
temporary.unlink(missing_ok=True)
|
|
248
|
+
return target, True
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def validate_repository(value: Path) -> Path:
|
|
252
|
+
requested = value.expanduser().resolve()
|
|
253
|
+
if not requested.is_dir():
|
|
254
|
+
raise NotADirectoryError(
|
|
255
|
+
f"repository directory does not exist: {requested}"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
repository = repository_root(requested)
|
|
259
|
+
launcher = repository / ".venv" / "bin" / "algorithm-plugin"
|
|
260
|
+
if not launcher.is_file():
|
|
261
|
+
raise FileNotFoundError(
|
|
262
|
+
f"repository virtualenv CLI is missing: {launcher}"
|
|
263
|
+
)
|
|
264
|
+
if not os.access(launcher, os.X_OK):
|
|
265
|
+
raise PermissionError(
|
|
266
|
+
f"repository virtualenv CLI is not executable: {launcher}"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
environment = os.environ.copy()
|
|
270
|
+
environment.pop("PYTHONPATH", None)
|
|
271
|
+
for command in ("run", "serve"):
|
|
272
|
+
try:
|
|
273
|
+
completed = subprocess.run(
|
|
274
|
+
[str(launcher), command, "--help"],
|
|
275
|
+
cwd=repository,
|
|
276
|
+
env=environment,
|
|
277
|
+
stdout=subprocess.PIPE,
|
|
278
|
+
stderr=subprocess.PIPE,
|
|
279
|
+
text=True,
|
|
280
|
+
)
|
|
281
|
+
except OSError as exc:
|
|
282
|
+
raise RuntimeError(
|
|
283
|
+
f"cannot execute repository virtualenv CLI {launcher}: {exc}"
|
|
284
|
+
) from exc
|
|
285
|
+
if completed.returncode:
|
|
286
|
+
detail = (
|
|
287
|
+
completed.stderr.strip()
|
|
288
|
+
or completed.stdout.strip()
|
|
289
|
+
or "no output"
|
|
290
|
+
)
|
|
291
|
+
raise RuntimeError(
|
|
292
|
+
f"{launcher} {command} --help failed with exit code "
|
|
293
|
+
f"{completed.returncode}: {detail}"
|
|
294
|
+
)
|
|
295
|
+
return repository
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def validate_configure_runtime(repository: Path) -> Path:
|
|
299
|
+
expected = (repository / ".venv").resolve()
|
|
300
|
+
actual = Path(sys.prefix).resolve()
|
|
301
|
+
if actual != expected:
|
|
302
|
+
launcher = expected / "bin" / "algorithm-plugin"
|
|
303
|
+
raise RuntimeError(
|
|
304
|
+
"configure must run from the repository virtualenv; use: "
|
|
305
|
+
f"sudo {launcher} configure --repository {repository}"
|
|
306
|
+
)
|
|
307
|
+
return expected
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def resolve_config_path(
|
|
311
|
+
repository: Path,
|
|
312
|
+
output: Path,
|
|
313
|
+
) -> Path:
|
|
314
|
+
path = output.expanduser()
|
|
315
|
+
if not path.is_absolute():
|
|
316
|
+
path = repository / path
|
|
317
|
+
return path.resolve()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def registration_from_args(
|
|
321
|
+
args: argparse.Namespace,
|
|
322
|
+
repository: Path,
|
|
323
|
+
) -> dict[str, str] | None:
|
|
324
|
+
if args.local:
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
supplied = {
|
|
328
|
+
"computeUrl": args.compute_url,
|
|
329
|
+
"apiKey": args.api_key,
|
|
330
|
+
"publicUrl": args.public_url,
|
|
331
|
+
"inputRoot": args.input_root,
|
|
332
|
+
"workspaceRoot": args.workspace_root,
|
|
333
|
+
"cluster": args.cluster,
|
|
334
|
+
"namespace": args.namespace,
|
|
335
|
+
"nodeName": args.node_name,
|
|
336
|
+
}
|
|
337
|
+
missing = [
|
|
338
|
+
name
|
|
339
|
+
for name, value in supplied.items()
|
|
340
|
+
if not str(value or "").strip()
|
|
341
|
+
]
|
|
342
|
+
if missing:
|
|
343
|
+
raise ValueError(
|
|
344
|
+
"configuration registration requires "
|
|
345
|
+
+ ", ".join(missing)
|
|
346
|
+
+ "; use --local to disable registration"
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
registration = {
|
|
350
|
+
name: str(value)
|
|
351
|
+
for name, value in supplied.items()
|
|
352
|
+
}
|
|
353
|
+
registration["instanceKey"] = stable_instance_key(
|
|
354
|
+
repository / ".algorithm-plugin" / "instance-key",
|
|
355
|
+
requested=args.instance_key,
|
|
356
|
+
)
|
|
357
|
+
return registration
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def generate_config(
|
|
361
|
+
args: argparse.Namespace,
|
|
362
|
+
repository: Path,
|
|
363
|
+
) -> DeploymentConfig:
|
|
364
|
+
config = DeploymentConfig.generate(
|
|
365
|
+
output=args.config_output,
|
|
366
|
+
root=repository,
|
|
367
|
+
algorithm=args.algorithm,
|
|
368
|
+
host=args.host,
|
|
369
|
+
port=args.port,
|
|
370
|
+
webui=args.webui,
|
|
371
|
+
max_concurrency=args.max_concurrency,
|
|
372
|
+
scratch_dir=args.scratch_dir,
|
|
373
|
+
token=args.service_token,
|
|
374
|
+
registration=registration_from_args(args, repository),
|
|
375
|
+
gpu_ids=args.gpu_ids,
|
|
376
|
+
environment=dict(args.env),
|
|
377
|
+
)
|
|
378
|
+
config.save()
|
|
379
|
+
return config
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def execute(args: argparse.Namespace) -> int:
|
|
383
|
+
require_root()
|
|
384
|
+
repository = validate_repository(args.repository)
|
|
385
|
+
validate_configure_runtime(repository)
|
|
386
|
+
service_name = resolve_service_name(
|
|
387
|
+
repository,
|
|
388
|
+
args.service_name,
|
|
389
|
+
)
|
|
390
|
+
config_path = resolve_config_path(
|
|
391
|
+
repository,
|
|
392
|
+
args.config_output,
|
|
393
|
+
)
|
|
394
|
+
unit = render_systemd_unit(
|
|
395
|
+
repository=repository,
|
|
396
|
+
config_path=config_path,
|
|
397
|
+
service_name=service_name,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
inspect_systemd_unit(
|
|
401
|
+
service_name=service_name,
|
|
402
|
+
content=unit,
|
|
403
|
+
force=args.force,
|
|
404
|
+
)
|
|
405
|
+
config = generate_config(args, repository)
|
|
406
|
+
unit_path, changed = write_systemd_unit(
|
|
407
|
+
service_name=service_name,
|
|
408
|
+
content=unit,
|
|
409
|
+
force=args.force,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
unit_status = "updated" if changed else "unchanged"
|
|
413
|
+
print(f"Configured repository: {repository}")
|
|
414
|
+
print(f"Configuration: {config.path}")
|
|
415
|
+
print(f"Release manifest: {repository / 'release-manifest.json'}")
|
|
416
|
+
print(f"Systemd unit: {unit_path} ({unit_status})")
|
|
417
|
+
print("Next commands:")
|
|
418
|
+
print(" sudo systemctl daemon-reload")
|
|
419
|
+
print(f" sudo systemctl enable {service_name}.service")
|
|
420
|
+
print(f" sudo systemctl restart {service_name}.service")
|
|
421
|
+
return 0
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def env_bool(name: str, default: bool = False) -> bool:
|
|
10
|
+
value = os.getenv(name)
|
|
11
|
+
if value is None:
|
|
12
|
+
return default
|
|
13
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def json_object(value: str) -> dict[str, Any]:
|
|
17
|
+
try:
|
|
18
|
+
parsed = json.loads(value)
|
|
19
|
+
except json.JSONDecodeError as exc:
|
|
20
|
+
raise argparse.ArgumentTypeError(f"invalid JSON: {exc}") from exc
|
|
21
|
+
if not isinstance(parsed, dict):
|
|
22
|
+
raise argparse.ArgumentTypeError("value must be a JSON object")
|
|
23
|
+
return parsed
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def gpu_ids(value: str) -> list[int]:
|
|
27
|
+
if not value.strip():
|
|
28
|
+
return []
|
|
29
|
+
try:
|
|
30
|
+
return [int(item.strip()) for item in value.split(",")]
|
|
31
|
+
except ValueError as exc:
|
|
32
|
+
raise argparse.ArgumentTypeError(
|
|
33
|
+
"GPU IDs must be comma-separated integers"
|
|
34
|
+
) from exc
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def environment(value: str) -> tuple[str, str]:
|
|
38
|
+
name, separator, setting = value.partition("=")
|
|
39
|
+
if not separator or not name or not name.replace("_", "").isalnum():
|
|
40
|
+
raise argparse.ArgumentTypeError("environment must use NAME=VALUE")
|
|
41
|
+
if name.startswith("LDP_") or name.startswith("ALGORITHM_"):
|
|
42
|
+
raise argparse.ArgumentTypeError(
|
|
43
|
+
"SDK-managed LDP_/ALGORITHM_ settings must use dedicated options"
|
|
44
|
+
)
|
|
45
|
+
return name, setting
|