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