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.
Files changed (50) hide show
  1. ls_algorithm_plugin_sdk-0.3.1/PKG-INFO +89 -0
  2. ls_algorithm_plugin_sdk-0.3.1/README.md +74 -0
  3. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/pyproject.toml +1 -1
  4. ls_algorithm_plugin_sdk-0.3.1/src/algorithm_plugin_sdk/cli.py +44 -0
  5. ls_algorithm_plugin_sdk-0.3.1/src/algorithm_plugin_sdk/cli_impl/__init__.py +1 -0
  6. ls_algorithm_plugin_sdk-0.3.1/src/algorithm_plugin_sdk/cli_impl/configure.py +434 -0
  7. ls_algorithm_plugin_sdk-0.3.1/src/algorithm_plugin_sdk/cli_impl/parsing.py +45 -0
  8. ls_algorithm_plugin_sdk-0.3.1/src/algorithm_plugin_sdk/cli_impl/run.py +164 -0
  9. ls_algorithm_plugin_sdk-0.3.1/src/algorithm_plugin_sdk/cli_impl/serve.py +130 -0
  10. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/src/algorithm_plugin_sdk/deployment.py +52 -16
  11. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/src/algorithm_plugin_sdk/examples/example_algorithm.py +2 -2
  12. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/src/algorithm_plugin_sdk/examples/simulated_algorithm.py +1 -1
  13. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/src/algorithm_plugin_sdk/models.py +78 -99
  14. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/src/algorithm_plugin_sdk/release.py +81 -0
  15. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/src/algorithm_plugin_sdk/runner.py +23 -45
  16. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/src/algorithm_plugin_sdk/service.py +1 -2
  17. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/src/algorithm_plugin_sdk/webui/index.html +1 -1
  18. ls_algorithm_plugin_sdk-0.3.1/src/ls_algorithm_plugin_sdk.egg-info/PKG-INFO +89 -0
  19. {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
  20. ls_algorithm_plugin_sdk-0.3.1/tests/test_cli.py +53 -0
  21. ls_algorithm_plugin_sdk-0.3.1/tests/test_configure.py +566 -0
  22. ls_algorithm_plugin_sdk-0.3.1/tests/test_deployment.py +223 -0
  23. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/tests/test_models.py +28 -14
  24. ls_algorithm_plugin_sdk-0.3.1/tests/test_release.py +174 -0
  25. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/tests/test_runner.py +81 -13
  26. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/tests/test_service.py +3 -3
  27. ls_algorithm_plugin_sdk-0.2.5/PKG-INFO +0 -87
  28. ls_algorithm_plugin_sdk-0.2.5/README.md +0 -72
  29. ls_algorithm_plugin_sdk-0.2.5/src/algorithm_plugin_sdk/cli.py +0 -542
  30. ls_algorithm_plugin_sdk-0.2.5/src/ls_algorithm_plugin_sdk.egg-info/PKG-INFO +0 -87
  31. ls_algorithm_plugin_sdk-0.2.5/tests/test_deployment.py +0 -90
  32. ls_algorithm_plugin_sdk-0.2.5/tests/test_release.py +0 -60
  33. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/setup.cfg +0 -0
  34. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/src/algorithm_plugin_sdk/__init__.py +0 -0
  35. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/src/algorithm_plugin_sdk/algorithm.py +0 -0
  36. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/src/algorithm_plugin_sdk/context.py +0 -0
  37. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/src/algorithm_plugin_sdk/errors.py +0 -0
  38. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/src/algorithm_plugin_sdk/examples/__init__.py +0 -0
  39. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/src/algorithm_plugin_sdk/loader.py +0 -0
  40. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/src/algorithm_plugin_sdk/registration.py +0 -0
  41. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/src/algorithm_plugin_sdk/webui/__init__.py +0 -0
  42. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/src/algorithm_plugin_sdk/webui/app.css +0 -0
  43. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/src/algorithm_plugin_sdk/webui/app.js +0 -0
  44. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/src/algorithm_plugin_sdk/webui_app.py +0 -0
  45. {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
  46. {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
  47. {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
  48. {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
  49. {ls_algorithm_plugin_sdk-0.2.5 → ls_algorithm_plugin_sdk-0.3.1}/tests/test_registration.py +0 -0
  50. {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
+ ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ls-algorithm-plugin-sdk"
7
- version = "0.2.5"
7
+ version = "0.3.1"
8
8
  description = "Protocol-independent runtime SDK for dataset algorithms"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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