ls-algorithm-plugin-sdk 0.3.0__tar.gz → 0.3.3__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 (47) hide show
  1. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/PKG-INFO +6 -17
  2. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/README.md +5 -16
  3. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/pyproject.toml +1 -1
  4. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/cli_impl/configure.py +86 -46
  5. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/cli_impl/run.py +5 -0
  6. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/cli_impl/serve.py +16 -2
  7. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/deployment.py +14 -0
  8. ls_algorithm_plugin_sdk-0.3.3/src/algorithm_plugin_sdk/gpu_isolation.py +27 -0
  9. ls_algorithm_plugin_sdk-0.3.3/src/algorithm_plugin_sdk/log_manager.py +76 -0
  10. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/service.py +80 -2
  11. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/ls_algorithm_plugin_sdk.egg-info/PKG-INFO +6 -17
  12. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/ls_algorithm_plugin_sdk.egg-info/SOURCES.txt +3 -0
  13. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/tests/test_configure.py +103 -37
  14. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/tests/test_deployment.py +43 -0
  15. ls_algorithm_plugin_sdk-0.3.3/tests/test_gpu_isolation.py +38 -0
  16. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/tests/test_runner.py +26 -0
  17. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/tests/test_service.py +20 -0
  18. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/setup.cfg +0 -0
  19. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/__init__.py +0 -0
  20. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/algorithm.py +0 -0
  21. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/cli.py +0 -0
  22. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/cli_impl/__init__.py +0 -0
  23. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/cli_impl/parsing.py +0 -0
  24. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/context.py +0 -0
  25. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/errors.py +0 -0
  26. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/examples/__init__.py +0 -0
  27. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/examples/example_algorithm.py +0 -0
  28. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/examples/simulated_algorithm.py +0 -0
  29. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/loader.py +0 -0
  30. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/models.py +0 -0
  31. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/registration.py +0 -0
  32. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/release.py +0 -0
  33. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/runner.py +0 -0
  34. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/webui/__init__.py +0 -0
  35. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/webui/app.css +0 -0
  36. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/webui/app.js +0 -0
  37. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/webui/index.html +0 -0
  38. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/algorithm_plugin_sdk/webui_app.py +0 -0
  39. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/ls_algorithm_plugin_sdk.egg-info/dependency_links.txt +0 -0
  40. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/ls_algorithm_plugin_sdk.egg-info/entry_points.txt +0 -0
  41. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/ls_algorithm_plugin_sdk.egg-info/requires.txt +0 -0
  42. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/src/ls_algorithm_plugin_sdk.egg-info/top_level.txt +0 -0
  43. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/tests/test_cli.py +0 -0
  44. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/tests/test_models.py +0 -0
  45. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/tests/test_registration.py +0 -0
  46. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/tests/test_release.py +0 -0
  47. {ls_algorithm_plugin_sdk-0.3.0 → ls_algorithm_plugin_sdk-0.3.3}/tests/test_sdk_automation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ls-algorithm-plugin-sdk
3
- Version: 0.3.0
3
+ Version: 0.3.3
4
4
  Summary: Protocol-independent runtime SDK for dataset algorithms
5
5
  Author: Ling Robotics
6
6
  License: Proprietary
@@ -15,10 +15,6 @@ Requires-Dist: build>=1.2; extra == "dev"
15
15
 
16
16
  # Algorithm Plugin SDK
17
17
 
18
- 算法仓库只实现一个 `Algorithm` 类并声明 entry point。SDK 统一提供单次运行、
19
- WebUI、HTTP service、release manifest、compute 注册和心跳。算法仓库不需要
20
- Dockerfile 或 Web 框架代码。
21
-
22
18
  ## 算法侧最小接口
23
19
 
24
20
  ```python
@@ -30,8 +26,7 @@ class MyAlgorithm(Algorithm):
30
26
  def execute(self, request, context): ...
31
27
  ```
32
28
 
33
- 算法包需要声明 `algorithm_plugin_sdk.algorithms` entry point。同一个 `execute()`
34
- 会被所有运行方式复用。
29
+ 算法包需要声明 `algorithm_plugin_sdk.algorithms` entry point。
35
30
 
36
31
  ## 单次运行
37
32
 
@@ -49,10 +44,9 @@ algorithm-plugin run camera-space-mano \
49
44
 
50
45
  目标算法仓库必须满足:
51
46
 
52
- - 仓库本身是 Git 工作树根目录,并且至少有一个 commit。
47
+ - 仓库本身是 Git 工作树根目录。
53
48
  - 仓库包含自己的 `.venv`。
54
49
  - `<repository>/.venv/bin/algorithm-plugin` 存在且可执行。
55
- - 该启动器的 `run --help` 和 `serve --help` 均可正常加载。
56
50
 
57
51
  建议将生成的 `release-manifest.json` 和 `algorithm-plugin.json` 加入算法仓库的
58
52
  `.gitignore`,不要手动编辑或提交它们。
@@ -64,16 +58,11 @@ sudo /srv/camera-space-mano/.venv/bin/algorithm-plugin configure \
64
58
  --repository /srv/camera-space-mano \
65
59
  --compute-url http://compute:5180 \
66
60
  --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 \
61
+ --port 9030 \
70
62
  --cluster production \
71
- --namespace camera-space-mano \
72
- --node-name 10.0.0.4 \
73
63
  --gpu-ids 0,1
74
64
  ```
75
65
 
76
- service name 默认使用仓库目录名,也可以通过 `--service-name` 指定。
77
66
  重复执行 configure 需要显式提供 `--force`。
78
67
 
79
68
  确认输出后执行:
@@ -82,7 +71,7 @@ service name 默认使用仓库目录名,也可以通过 `--service-name` 指
82
71
  sudo systemctl daemon-reload
83
72
  sudo systemctl enable camera-space-mano.service
84
73
  sudo systemctl restart camera-space-mano.service
85
- sudo systemctl status camera-space-mano.service
74
+ sudo systemctl status camera-space-mano.service --no-pager
86
75
  ```
87
76
 
88
77
  ## serve
@@ -95,6 +84,6 @@ sudo systemctl status camera-space-mano.service
95
84
  ## 安装与测试
96
85
 
97
86
  ```bash
98
- python -m pip install '.[service]'
87
+ python -m pip install .
99
88
  python -m unittest discover -s tests -v
100
89
  ```
@@ -1,9 +1,5 @@
1
1
  # Algorithm Plugin SDK
2
2
 
3
- 算法仓库只实现一个 `Algorithm` 类并声明 entry point。SDK 统一提供单次运行、
4
- WebUI、HTTP service、release manifest、compute 注册和心跳。算法仓库不需要
5
- Dockerfile 或 Web 框架代码。
6
-
7
3
  ## 算法侧最小接口
8
4
 
9
5
  ```python
@@ -15,8 +11,7 @@ class MyAlgorithm(Algorithm):
15
11
  def execute(self, request, context): ...
16
12
  ```
17
13
 
18
- 算法包需要声明 `algorithm_plugin_sdk.algorithms` entry point。同一个 `execute()`
19
- 会被所有运行方式复用。
14
+ 算法包需要声明 `algorithm_plugin_sdk.algorithms` entry point。
20
15
 
21
16
  ## 单次运行
22
17
 
@@ -34,10 +29,9 @@ algorithm-plugin run camera-space-mano \
34
29
 
35
30
  目标算法仓库必须满足:
36
31
 
37
- - 仓库本身是 Git 工作树根目录,并且至少有一个 commit。
32
+ - 仓库本身是 Git 工作树根目录。
38
33
  - 仓库包含自己的 `.venv`。
39
34
  - `<repository>/.venv/bin/algorithm-plugin` 存在且可执行。
40
- - 该启动器的 `run --help` 和 `serve --help` 均可正常加载。
41
35
 
42
36
  建议将生成的 `release-manifest.json` 和 `algorithm-plugin.json` 加入算法仓库的
43
37
  `.gitignore`,不要手动编辑或提交它们。
@@ -49,16 +43,11 @@ sudo /srv/camera-space-mano/.venv/bin/algorithm-plugin configure \
49
43
  --repository /srv/camera-space-mano \
50
44
  --compute-url http://compute:5180 \
51
45
  --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 \
46
+ --port 9030 \
55
47
  --cluster production \
56
- --namespace camera-space-mano \
57
- --node-name 10.0.0.4 \
58
48
  --gpu-ids 0,1
59
49
  ```
60
50
 
61
- service name 默认使用仓库目录名,也可以通过 `--service-name` 指定。
62
51
  重复执行 configure 需要显式提供 `--force`。
63
52
 
64
53
  确认输出后执行:
@@ -67,7 +56,7 @@ service name 默认使用仓库目录名,也可以通过 `--service-name` 指
67
56
  sudo systemctl daemon-reload
68
57
  sudo systemctl enable camera-space-mano.service
69
58
  sudo systemctl restart camera-space-mano.service
70
- sudo systemctl status camera-space-mano.service
59
+ sudo systemctl status camera-space-mano.service --no-pager
71
60
  ```
72
61
 
73
62
  ## serve
@@ -80,6 +69,6 @@ sudo systemctl status camera-space-mano.service
80
69
  ## 安装与测试
81
70
 
82
71
  ```bash
83
- python -m pip install '.[service]'
72
+ python -m pip install .
84
73
  python -m unittest discover -s tests -v
85
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.3.0"
7
+ version = "0.3.3"
8
8
  description = "Protocol-independent runtime SDK for dataset algorithms"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,11 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import ipaddress
4
5
  import os
5
6
  import re
7
+ import socket
6
8
  import subprocess
7
9
  import sys
8
10
  from pathlib import Path
11
+ from urllib.parse import urlparse
9
12
 
10
13
  from ..deployment import (
11
14
  DEFAULT_CONFIG_NAME,
@@ -13,10 +16,15 @@ from ..deployment import (
13
16
  repository_root,
14
17
  stable_instance_key,
15
18
  )
16
- from .parsing import environment, gpu_ids
19
+ from .parsing import environment, gpu_ids, json_object
17
20
 
18
21
 
19
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
+ )
20
28
 
21
29
  SERVICE_NAME_PATTERN = re.compile(
22
30
  r"^[A-Za-z0-9][A-Za-z0-9_.@-]*$"
@@ -48,7 +56,11 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
48
56
  action=argparse.BooleanOptionalAction,
49
57
  default=True,
50
58
  )
51
- parser.add_argument("--max-concurrency", type=int, default=1)
59
+ parser.add_argument(
60
+ "--max-concurrency",
61
+ type=int,
62
+ help="maximum concurrent executions (default: GPU count, or 1)",
63
+ )
52
64
  parser.add_argument("--scratch-dir")
53
65
  parser.add_argument(
54
66
  "--gpu-ids",
@@ -56,6 +68,12 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
56
68
  default=[],
57
69
  help="GPU IDs injected into every service execution",
58
70
  )
71
+ parser.add_argument(
72
+ "--default-parameters",
73
+ type=json_object,
74
+ default={},
75
+ help="JSON object used to fill missing request parameters",
76
+ )
59
77
  parser.add_argument("--service-token")
60
78
  parser.add_argument(
61
79
  "--compute-url",
@@ -65,18 +83,6 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
65
83
  "--api-key",
66
84
  default=os.getenv("LDP_INTERNAL_API_KEY"),
67
85
  )
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
86
  parser.add_argument(
81
87
  "--instance-key",
82
88
  default=os.getenv("LDP_PLUGIN_INSTANCE_KEY"),
@@ -85,14 +91,6 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
85
91
  "--cluster",
86
92
  default=os.getenv("LDP_CLUSTER"),
87
93
  )
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
94
  parser.add_argument(
97
95
  "--local",
98
96
  action="store_true",
@@ -105,10 +103,6 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
105
103
  default=[],
106
104
  metavar="NAME=VALUE",
107
105
  )
108
- parser.add_argument(
109
- "--service-name",
110
- help="systemd unit name without .service; defaults to repository name",
111
- )
112
106
  parser.add_argument(
113
107
  "--force",
114
108
  action="store_true",
@@ -119,33 +113,35 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
119
113
 
120
114
  def resolve_service_name(
121
115
  repository: Path,
122
- requested: str | None,
123
116
  ) -> str:
124
- name = requested or repository.name
117
+ name = repository.name
125
118
  if name.endswith(".service"):
126
119
  raise ValueError(
127
- "service name must not include the .service suffix"
120
+ "repository name must not include the .service suffix"
128
121
  )
129
122
  if not SERVICE_NAME_PATTERN.fullmatch(name):
130
123
  raise ValueError(
131
- "service name may contain only letters, numbers, "
124
+ "repository name may contain only letters, numbers, "
132
125
  "underscore, dot, @, and hyphen"
133
126
  )
134
127
  return name
135
128
 
136
129
 
137
- def _quote_systemd_path(value: Path) -> str:
130
+ def _escape_systemd_path(value: Path) -> str:
138
131
  text = str(value)
139
132
  if "\n" in text or "\r" in text or "\0" in text:
140
133
  raise ValueError(
141
134
  f"systemd path contains control characters: {value}"
142
135
  )
143
- escaped = (
136
+ return (
144
137
  text.replace("\\", "\\\\")
145
138
  .replace('"', '\\"')
146
139
  .replace("%", "%%")
147
140
  )
148
- return f'"{escaped}"'
141
+
142
+
143
+ def _quote_systemd_path(value: Path) -> str:
144
+ return f'"{_escape_systemd_path(value)}"'
149
145
 
150
146
 
151
147
  def render_systemd_unit(
@@ -153,10 +149,18 @@ def render_systemd_unit(
153
149
  repository: Path,
154
150
  config_path: Path,
155
151
  service_name: str,
152
+ gpu_ids: list[int] | None = None,
156
153
  ) -> str:
157
154
  root = repository.resolve()
155
+ working_directory = root / "jobs"
158
156
  launcher = root / ".venv" / "bin" / "algorithm-plugin"
159
157
  config = config_path.resolve()
158
+ environment = ["Environment=PYTHONUNBUFFERED=1"]
159
+ if gpu_ids:
160
+ environment.append(
161
+ "Environment=CUDA_VISIBLE_DEVICES="
162
+ + ",".join(str(gpu_id) for gpu_id in gpu_ids)
163
+ )
160
164
  return "\n".join(
161
165
  (
162
166
  "[Unit]",
@@ -167,12 +171,13 @@ def render_systemd_unit(
167
171
  "[Service]",
168
172
  "Type=simple",
169
173
  "User=root",
170
- f"WorkingDirectory={_quote_systemd_path(root)}",
174
+ f"WorkingDirectory={_escape_systemd_path(working_directory)}",
171
175
  (
172
176
  f"ExecStart={_quote_systemd_path(launcher)} "
173
177
  f"serve --config {_quote_systemd_path(config)}"
174
178
  ),
175
- "Environment=PYTHONUNBUFFERED=1",
179
+ *environment,
180
+ "LimitNOFILE=524288",
176
181
  "Restart=on-failure",
177
182
  "RestartSec=5s",
178
183
  "",
@@ -317,6 +322,35 @@ def resolve_config_path(
317
322
  return path.resolve()
318
323
 
319
324
 
325
+ def resolve_private_ipv4(compute_url: str) -> str:
326
+ parsed = urlparse(compute_url)
327
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
328
+ raise ValueError("compute URL must be an http or https URL")
329
+ try:
330
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
331
+ except ValueError as exc:
332
+ raise ValueError("compute URL has an invalid port") from exc
333
+
334
+ try:
335
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
336
+ probe.connect((parsed.hostname, port))
337
+ value = probe.getsockname()[0]
338
+ except OSError as exc:
339
+ raise RuntimeError(
340
+ f"cannot resolve the local IP used to reach {parsed.hostname}: {exc}"
341
+ ) from exc
342
+
343
+ address = ipaddress.ip_address(value)
344
+ if not isinstance(address, ipaddress.IPv4Address) or not any(
345
+ address in network for network in PRIVATE_IPV4_NETWORKS
346
+ ):
347
+ raise RuntimeError(
348
+ f"the local IP used to reach {parsed.hostname} is not a private IPv4: "
349
+ f"{address}"
350
+ )
351
+ return str(address)
352
+
353
+
320
354
  def registration_from_args(
321
355
  args: argparse.Namespace,
322
356
  repository: Path,
@@ -327,12 +361,9 @@ def registration_from_args(
327
361
  supplied = {
328
362
  "computeUrl": args.compute_url,
329
363
  "apiKey": args.api_key,
330
- "publicUrl": args.public_url,
331
- "inputRoot": args.input_root,
332
- "workspaceRoot": args.workspace_root,
364
+ "inputRoot": str(INPUT_ROOT),
365
+ "workspaceRoot": str(repository.resolve()),
333
366
  "cluster": args.cluster,
334
- "namespace": args.namespace,
335
- "nodeName": args.node_name,
336
367
  }
337
368
  missing = [
338
369
  name
@@ -350,6 +381,9 @@ def registration_from_args(
350
381
  name: str(value)
351
382
  for name, value in supplied.items()
352
383
  }
384
+ private_ipv4 = resolve_private_ipv4(registration["computeUrl"])
385
+ registration["publicUrl"] = f"http://{private_ipv4}:{args.port}"
386
+ registration["nodeName"] = private_ipv4
353
387
  registration["instanceKey"] = stable_instance_key(
354
388
  repository / ".algorithm-plugin" / "instance-key",
355
389
  requested=args.instance_key,
@@ -361,6 +395,11 @@ def generate_config(
361
395
  args: argparse.Namespace,
362
396
  repository: Path,
363
397
  ) -> DeploymentConfig:
398
+ max_concurrency = (
399
+ args.max_concurrency
400
+ if args.max_concurrency is not None
401
+ else max(len(args.gpu_ids), 1)
402
+ )
364
403
  config = DeploymentConfig.generate(
365
404
  output=args.config_output,
366
405
  root=repository,
@@ -368,12 +407,13 @@ def generate_config(
368
407
  host=args.host,
369
408
  port=args.port,
370
409
  webui=args.webui,
371
- max_concurrency=args.max_concurrency,
410
+ max_concurrency=max_concurrency,
372
411
  scratch_dir=args.scratch_dir,
373
412
  token=args.service_token,
374
413
  registration=registration_from_args(args, repository),
375
414
  gpu_ids=args.gpu_ids,
376
415
  environment=dict(args.env),
416
+ default_parameters=args.default_parameters,
377
417
  )
378
418
  config.save()
379
419
  return config
@@ -383,10 +423,7 @@ def execute(args: argparse.Namespace) -> int:
383
423
  require_root()
384
424
  repository = validate_repository(args.repository)
385
425
  validate_configure_runtime(repository)
386
- service_name = resolve_service_name(
387
- repository,
388
- args.service_name,
389
- )
426
+ service_name = resolve_service_name(repository)
390
427
  config_path = resolve_config_path(
391
428
  repository,
392
429
  args.config_output,
@@ -395,6 +432,7 @@ def execute(args: argparse.Namespace) -> int:
395
432
  repository=repository,
396
433
  config_path=config_path,
397
434
  service_name=service_name,
435
+ gpu_ids=args.gpu_ids,
398
436
  )
399
437
 
400
438
  inspect_systemd_unit(
@@ -402,6 +440,7 @@ def execute(args: argparse.Namespace) -> int:
402
440
  content=unit,
403
441
  force=args.force,
404
442
  )
443
+ (repository / "jobs").mkdir(exist_ok=True)
405
444
  config = generate_config(args, repository)
406
445
  unit_path, changed = write_systemd_unit(
407
446
  service_name=service_name,
@@ -418,4 +457,5 @@ def execute(args: argparse.Namespace) -> int:
418
457
  print(" sudo systemctl daemon-reload")
419
458
  print(f" sudo systemctl enable {service_name}.service")
420
459
  print(f" sudo systemctl restart {service_name}.service")
421
- return 0
460
+ print(f" sudo systemctl status {service_name}.service --no-pager")
461
+ return 0
@@ -17,6 +17,7 @@ from typing import Any, Iterator
17
17
 
18
18
  from ..context import ExecutionContext, ProgressSnapshot
19
19
  from ..errors import ExecutionCancelled
20
+ from ..gpu_isolation import apply_gpu_isolation
20
21
  from ..loader import load_algorithm
21
22
  from ..models import AlgorithmRequest
22
23
  from ..runner import AlgorithmRunner
@@ -125,6 +126,10 @@ def cancel_on_interrupt(
125
126
 
126
127
  def execute(args: argparse.Namespace) -> int:
127
128
  request = request_from_args(args)
129
+ request = replace(
130
+ request,
131
+ gpu_ids=apply_gpu_isolation(request.gpu_ids),
132
+ )
128
133
  progress = TerminalProgress()
129
134
  context = ExecutionContext(
130
135
  f"cli-{id(args)}",
@@ -5,6 +5,7 @@ import os
5
5
  from pathlib import Path
6
6
 
7
7
  from ..deployment import DEFAULT_CONFIG_NAME, DeploymentConfig
8
+ from ..gpu_isolation import apply_gpu_isolation
8
9
  from ..loader import load_algorithm
9
10
  from ..registration import PluginRegistrationAgent
10
11
  from ..release import ReleaseManifest
@@ -78,9 +79,12 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
78
79
 
79
80
 
80
81
  def execute(args: argparse.Namespace) -> int:
82
+ config: DeploymentConfig | None = None
83
+ service: dict[str, object] | None = None
81
84
  if args.config:
82
- config = DeploymentConfig.load(args.config)
83
- config.save()
85
+ # Apply the process mask before refreshing/loading the algorithm. A
86
+ # repository module may import CUDA during module import.
87
+ config = DeploymentConfig.load(args.config, refresh=False)
84
88
  service = config.payload["service"]
85
89
  args.algorithm = config.algorithm_reference
86
90
  args.host = str(service["host"])
@@ -96,6 +100,11 @@ def execute(args: argparse.Namespace) -> int:
96
100
  elif not args.algorithm:
97
101
  raise ValueError("algorithm is required unless --config is provided")
98
102
 
103
+ args.gpu_ids = apply_gpu_isolation(args.gpu_ids)
104
+ if config is not None:
105
+ config.refresh()
106
+ config.save()
107
+
99
108
  algorithm_reference = str(args.algorithm)
100
109
  algorithm = load_algorithm(algorithm_reference)
101
110
 
@@ -105,6 +114,10 @@ def execute(args: argparse.Namespace) -> int:
105
114
  release = ReleaseManifest.discover(algorithm, start=Path.cwd(), required=False)
106
115
  manager = ExecutionManager(
107
116
  AlgorithmRunner(algorithm),
117
+ default_parameters=(
118
+ service.get("defaultParameters", {})
119
+ if service is not None else None
120
+ ),
108
121
  max_concurrent_executions=args.max_concurrent_executions,
109
122
  scratch_dir=args.scratch_dir,
110
123
  gpu_ids=args.gpu_ids,
@@ -113,6 +126,7 @@ def execute(args: argparse.Namespace) -> int:
113
126
  retry_seconds=args.retry_seconds,
114
127
  retry_jitter_ratio=args.retry_jitter_ratio,
115
128
  release_manifest=release,
129
+ repository=(config.repository if config is not None else Path.cwd()),
116
130
  )
117
131
  registration = PluginRegistrationAgent.from_env(manager.manifest, manager.heartbeat)
118
132
  if registration is not None and release is None:
@@ -145,6 +145,7 @@ class DeploymentConfig:
145
145
  registration: dict[str, str] | None,
146
146
  gpu_ids: list[int] | None = None,
147
147
  environment: dict[str, str] | None = None,
148
+ default_parameters: dict[str, Any] | None = None,
148
149
  ) -> "DeploymentConfig":
149
150
  repository = repository_root(root)
150
151
  _make_repository_importable(repository)
@@ -154,6 +155,8 @@ class DeploymentConfig:
154
155
  instance,
155
156
  repository=repository,
156
157
  )
158
+ if registration is not None:
159
+ registration = {**registration, "namespace": release.name}
157
160
  release_path = release.save(
158
161
  repository / "release-manifest.json"
159
162
  )
@@ -188,6 +191,7 @@ class DeploymentConfig:
188
191
  "scratchDir": _absolute(scratch_dir, repository),
189
192
  "gpuIds": configured_gpu_ids,
190
193
  "token": token or None,
194
+ "defaultParameters": dict(default_parameters or {}),
191
195
  },
192
196
  "registration": registration,
193
197
  "environment": dict(sorted((environment or {}).items())),
@@ -271,6 +275,16 @@ class DeploymentConfig:
271
275
  ) or len(gpu_ids) != len(set(gpu_ids)):
272
276
  raise RuntimeError("service gpuIds must be unique non-negative integers")
273
277
 
278
+ default_parameters = service.get("defaultParameters", {})
279
+ if not isinstance(default_parameters, dict):
280
+ raise RuntimeError("defaultParameters must be an object")
281
+ if any(not isinstance(key, str) for key in default_parameters):
282
+ raise RuntimeError("defaultParameters keys must be strings")
283
+ try:
284
+ json.dumps(default_parameters)
285
+ except (TypeError, ValueError) as exc:
286
+ raise RuntimeError("defaultParameters must be JSON serializable") from exc
287
+
274
288
  def _validate_registration(self) -> None:
275
289
  registration = self.payload.get("registration")
276
290
  if registration is None:
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Sequence
5
+
6
+
7
+ def apply_gpu_isolation(gpu_ids: Sequence[int] | None) -> list[int]:
8
+ """Mask physical GPUs for this process and return local GPU ordinals."""
9
+ if gpu_ids is None:
10
+ return []
11
+
12
+ physical_gpu_ids = list(gpu_ids)
13
+ if any(
14
+ isinstance(gpu_id, bool)
15
+ or not isinstance(gpu_id, int)
16
+ or gpu_id < 0
17
+ for gpu_id in physical_gpu_ids
18
+ ) or len(physical_gpu_ids) != len(set(physical_gpu_ids)):
19
+ raise ValueError("gpu_ids must be unique non-negative integers")
20
+
21
+ if not physical_gpu_ids:
22
+ return []
23
+
24
+ os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(
25
+ str(gpu_id) for gpu_id in physical_gpu_ids
26
+ )
27
+ return list(range(len(physical_gpu_ids)))
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ import threading
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .context import utc_now
11
+
12
+ _SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
13
+
14
+
15
+ def _safe_name(value: str) -> str:
16
+ cleaned = _SAFE_NAME.sub("_", str(value).strip()).strip("._")
17
+ return cleaned or "job"
18
+
19
+
20
+ class ExecutionLog:
21
+ """Per-execution, line-buffered log sink attached to context.logger."""
22
+
23
+ def __init__(self, *, log_dir: str | Path, job_id: str, execution_id: str, logger: logging.Logger) -> None:
24
+ directory = Path(log_dir).expanduser()
25
+ directory.mkdir(parents=True, exist_ok=True)
26
+ path = directory / f"{_safe_name(job_id)}.log"
27
+ if path.exists():
28
+ path = directory / f"{_safe_name(job_id)}-{_safe_name(execution_id)}.log"
29
+ self.path = path
30
+ self._stream = path.open("a", encoding="utf-8", buffering=1)
31
+ self._lock = threading.RLock()
32
+ self._handler = _FlushFileHandler(self._stream)
33
+ self._handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
34
+ self._handler.setLevel(logging.DEBUG)
35
+ self.logger = logger
36
+ self.logger.setLevel(logging.DEBUG)
37
+ self.logger.addHandler(self._handler)
38
+ self._closed = False
39
+
40
+ def write_event(self, event: str, **values: Any) -> None:
41
+ payload = {"event": event, "timestamp": utc_now(), **values}
42
+ with self._lock:
43
+ if self._closed:
44
+ return
45
+ self._stream.write(json.dumps(payload, ensure_ascii=False, default=str) + "\n")
46
+ self._stream.flush()
47
+
48
+ def close(self) -> None:
49
+ with self._lock:
50
+ if self._closed:
51
+ return
52
+ self._closed = True
53
+ self.logger.removeHandler(self._handler)
54
+ self._handler.close()
55
+ self._stream.close()
56
+
57
+
58
+ class _FlushFileHandler(logging.Handler):
59
+ def __init__(self, stream: Any) -> None:
60
+ super().__init__()
61
+ self.stream = stream
62
+ self._lock = threading.RLock()
63
+
64
+ def emit(self, record: logging.LogRecord) -> None:
65
+ try:
66
+ with self._lock:
67
+ self.stream.write(self.format(record) + "\n")
68
+ self.stream.flush()
69
+ except Exception:
70
+ self.handleError(record)
71
+
72
+ def close(self) -> None:
73
+ try:
74
+ self.stream.flush()
75
+ finally:
76
+ super().close()