harbor-hypeman 0.1.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.
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: harbor-hypeman
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hypeman environment backend for Harbor Framework
|
|
5
|
+
Author: Kernel
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Requires-Dist: dockerfile-parse>=2.0.1
|
|
8
|
+
Requires-Dist: harbor>=0.21.0,<0.22
|
|
9
|
+
Requires-Dist: hypeman>=0.1.0,<0.2
|
|
10
|
+
Requires-Dist: pathspec>=1.0.3
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Project-URL: Changelog, https://github.com/kernel/harbor-hypeman/blob/main/CHANGELOG.md
|
|
13
|
+
Project-URL: Repository, https://github.com/kernel/harbor-hypeman
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# harbor-hypeman
|
|
17
|
+
|
|
18
|
+
Run [Harbor Framework](https://github.com/harbor-framework/harbor) evaluations in [Hypeman](https://github.com/kernel/hypeman) sandboxes.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv tool install harbor --with harbor-hypeman
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Set the Hypeman credentials used by the generated Python SDK:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
export HYPEMAN_API_KEY=...
|
|
30
|
+
export HYPEMAN_BASE_URL=https://hypeman.example.com
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`HYPEMAN_BASE_URL` is optional when Hypeman is available at the SDK default, `http://localhost:4973`.
|
|
34
|
+
|
|
35
|
+
## Run
|
|
36
|
+
|
|
37
|
+
Pass the third-party environment import path to Harbor:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
harbor run \
|
|
41
|
+
--dataset terminal-bench@2.0 \
|
|
42
|
+
--agent codex \
|
|
43
|
+
--model openai/gpt-5.6 \
|
|
44
|
+
--env harbor_hypeman:HypemanEnvironment
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The backend supports task environments defined by either:
|
|
48
|
+
|
|
49
|
+
- `[environment].docker_image` in `task.toml`
|
|
50
|
+
- `environment/Dockerfile`
|
|
51
|
+
|
|
52
|
+
CPU, memory, and storage values map to Hypeman vCPUs, base memory, and writable overlay size. Harbor `public` and `no-network` modes map to attached and detached Hypeman networking.
|
|
53
|
+
|
|
54
|
+
## Behavior
|
|
55
|
+
|
|
56
|
+
- Dockerfile builds are cached by Harbor environment content hash and rebuilt with `--force-build`.
|
|
57
|
+
- Commands execute once through Hypeman's WebSocket API; transport failures after dispatch are not retried.
|
|
58
|
+
- Hypeman currently returns merged stdout/stderr. Harbor receives that output as `stdout` and `stderr=None`.
|
|
59
|
+
- Uploads and downloads use Hypeman's archive-aware WebSocket copy API.
|
|
60
|
+
- `stop(delete=False)` stops and preserves the instance; `stop(delete=True)` deletes it.
|
|
61
|
+
|
|
62
|
+
## Not supported
|
|
63
|
+
|
|
64
|
+
- Docker Compose or sidecar services
|
|
65
|
+
- network allowlists or runtime network-policy changes
|
|
66
|
+
- GPUs, TPUs, and Windows containers
|
|
67
|
+
- interactive `harbor ... --attach`
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# harbor-hypeman
|
|
2
|
+
|
|
3
|
+
Run [Harbor Framework](https://github.com/harbor-framework/harbor) evaluations in [Hypeman](https://github.com/kernel/hypeman) sandboxes.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv tool install harbor --with harbor-hypeman
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Set the Hypeman credentials used by the generated Python SDK:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
export HYPEMAN_API_KEY=...
|
|
15
|
+
export HYPEMAN_BASE_URL=https://hypeman.example.com
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`HYPEMAN_BASE_URL` is optional when Hypeman is available at the SDK default, `http://localhost:4973`.
|
|
19
|
+
|
|
20
|
+
## Run
|
|
21
|
+
|
|
22
|
+
Pass the third-party environment import path to Harbor:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
harbor run \
|
|
26
|
+
--dataset terminal-bench@2.0 \
|
|
27
|
+
--agent codex \
|
|
28
|
+
--model openai/gpt-5.6 \
|
|
29
|
+
--env harbor_hypeman:HypemanEnvironment
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The backend supports task environments defined by either:
|
|
33
|
+
|
|
34
|
+
- `[environment].docker_image` in `task.toml`
|
|
35
|
+
- `environment/Dockerfile`
|
|
36
|
+
|
|
37
|
+
CPU, memory, and storage values map to Hypeman vCPUs, base memory, and writable overlay size. Harbor `public` and `no-network` modes map to attached and detached Hypeman networking.
|
|
38
|
+
|
|
39
|
+
## Behavior
|
|
40
|
+
|
|
41
|
+
- Dockerfile builds are cached by Harbor environment content hash and rebuilt with `--force-build`.
|
|
42
|
+
- Commands execute once through Hypeman's WebSocket API; transport failures after dispatch are not retried.
|
|
43
|
+
- Hypeman currently returns merged stdout/stderr. Harbor receives that output as `stdout` and `stderr=None`.
|
|
44
|
+
- Uploads and downloads use Hypeman's archive-aware WebSocket copy API.
|
|
45
|
+
- `stop(delete=False)` stops and preserves the instance; `stop(delete=True)` deletes it.
|
|
46
|
+
|
|
47
|
+
## Not supported
|
|
48
|
+
|
|
49
|
+
- Docker Compose or sidecar services
|
|
50
|
+
- network allowlists or runtime network-policy changes
|
|
51
|
+
- GPUs, TPUs, and Windows containers
|
|
52
|
+
- interactive `harbor ... --attach`
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "harbor-hypeman"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Hypeman environment backend for Harbor Framework"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
authors = [{ name = "Kernel" }]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"dockerfile-parse>=2.0.1",
|
|
11
|
+
"harbor>=0.21.0,<0.22",
|
|
12
|
+
"hypeman>=0.1.0,<0.2",
|
|
13
|
+
"pathspec>=1.0.3",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Repository = "https://github.com/kernel/harbor-hypeman"
|
|
18
|
+
Changelog = "https://github.com/kernel/harbor-hypeman/blob/main/CHANGELOG.md"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=8.4.2",
|
|
23
|
+
"pytest-asyncio>=1.2.0",
|
|
24
|
+
"ruff>=0.15.0",
|
|
25
|
+
"ty>=0.0.49",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["uv_build>=0.8.4,<0.9.0"]
|
|
30
|
+
build-backend = "uv_build"
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
asyncio_mode = "auto"
|
|
35
|
+
|
|
36
|
+
[tool.ruff.lint]
|
|
37
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
38
|
+
|
|
39
|
+
[tool.ty.environment]
|
|
40
|
+
python = ".venv"
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import tarfile
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path, PurePosixPath
|
|
12
|
+
from typing import Any, override
|
|
13
|
+
|
|
14
|
+
from harbor.environments.base import (
|
|
15
|
+
BaseEnvironment,
|
|
16
|
+
ExecResult,
|
|
17
|
+
SandboxBuildFailedError,
|
|
18
|
+
)
|
|
19
|
+
from harbor.environments.capabilities import (
|
|
20
|
+
EnvironmentCapabilities,
|
|
21
|
+
EnvironmentResourceCapabilities,
|
|
22
|
+
)
|
|
23
|
+
from harbor.environments.definition import (
|
|
24
|
+
effective_exec_cwd,
|
|
25
|
+
parse_dockerfile_workdir,
|
|
26
|
+
require_agent_environment_definition,
|
|
27
|
+
should_use_prebuilt_docker_image,
|
|
28
|
+
)
|
|
29
|
+
from harbor.models.task.config import EnvironmentConfig, NetworkMode
|
|
30
|
+
from harbor.models.trial.paths import TrialPaths
|
|
31
|
+
from hypeman import AsyncHypeman, NotFoundError, omit
|
|
32
|
+
from hypeman.lib import (
|
|
33
|
+
cp_from_instance_async,
|
|
34
|
+
cp_to_instance_async,
|
|
35
|
+
exec_async,
|
|
36
|
+
)
|
|
37
|
+
from pathspec import GitIgnoreSpec
|
|
38
|
+
|
|
39
|
+
_BUILD_TAG = "harbor.environment_id"
|
|
40
|
+
_TERMINAL_BUILD_STATES = frozenset({"failed", "cancelled"})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HypemanEnvironment(BaseEnvironment):
|
|
44
|
+
"""Run a single-container Harbor environment on Hypeman."""
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
@override
|
|
48
|
+
def preflight(cls) -> None:
|
|
49
|
+
if not os.environ.get("HYPEMAN_API_KEY"):
|
|
50
|
+
raise SystemExit(
|
|
51
|
+
"Hypeman requires HYPEMAN_API_KEY to be set. "
|
|
52
|
+
"Set HYPEMAN_BASE_URL as well when the API is not on localhost."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
environment_dir: Path,
|
|
58
|
+
environment_name: str,
|
|
59
|
+
session_id: str,
|
|
60
|
+
trial_paths: TrialPaths,
|
|
61
|
+
task_env_config: EnvironmentConfig,
|
|
62
|
+
*args: object,
|
|
63
|
+
hypeman_client: AsyncHypeman | None = None,
|
|
64
|
+
**kwargs: Any,
|
|
65
|
+
) -> None:
|
|
66
|
+
self._client = hypeman_client or AsyncHypeman(max_retries=0)
|
|
67
|
+
self._instance_id: str | None = None
|
|
68
|
+
self._dockerfile_workdir = parse_dockerfile_workdir(
|
|
69
|
+
environment_dir / "Dockerfile"
|
|
70
|
+
)
|
|
71
|
+
super().__init__(
|
|
72
|
+
environment_dir=environment_dir,
|
|
73
|
+
environment_name=environment_name,
|
|
74
|
+
session_id=session_id,
|
|
75
|
+
trial_paths=trial_paths,
|
|
76
|
+
task_env_config=task_env_config,
|
|
77
|
+
**kwargs,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
@override
|
|
82
|
+
def type() -> str:
|
|
83
|
+
return "hypeman"
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
@override
|
|
87
|
+
def resource_capabilities(cls) -> EnvironmentResourceCapabilities:
|
|
88
|
+
return EnvironmentResourceCapabilities(cpu_limit=True, memory_limit=True)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
@override
|
|
92
|
+
def capabilities(self) -> EnvironmentCapabilities:
|
|
93
|
+
return EnvironmentCapabilities(disable_internet=True)
|
|
94
|
+
|
|
95
|
+
@override
|
|
96
|
+
def _validate_definition(self) -> None:
|
|
97
|
+
require_agent_environment_definition(
|
|
98
|
+
self.environment_dir,
|
|
99
|
+
docker_image=self.task_env_config.docker_image,
|
|
100
|
+
)
|
|
101
|
+
if (self.environment_dir / "docker-compose.yaml").exists():
|
|
102
|
+
raise ValueError(
|
|
103
|
+
"Hypeman supports Dockerfile and prebuilt-image environments, "
|
|
104
|
+
"not Docker Compose."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@override
|
|
108
|
+
async def start(self, force_build: bool) -> None:
|
|
109
|
+
if self._instance_id is not None:
|
|
110
|
+
raise RuntimeError("Hypeman environment is already started.")
|
|
111
|
+
|
|
112
|
+
image = await self._prepare_image(force_build)
|
|
113
|
+
instance = await self._client.instances.create(
|
|
114
|
+
image=image,
|
|
115
|
+
name=self._instance_name(),
|
|
116
|
+
entrypoint=["/bin/sh", "-c"],
|
|
117
|
+
cmd=["while true; do sleep 3600; done"],
|
|
118
|
+
env=self._startup_env(),
|
|
119
|
+
network={"enabled": self.network_policy.network_mode == NetworkMode.PUBLIC},
|
|
120
|
+
tags={
|
|
121
|
+
"harbor.managed": "true",
|
|
122
|
+
"harbor.environment_id": self.environment_id,
|
|
123
|
+
"harbor.session_id": self.session_id,
|
|
124
|
+
},
|
|
125
|
+
vcpus=self._effective_cpus if self._effective_cpus is not None else omit,
|
|
126
|
+
size=(
|
|
127
|
+
f"{self._effective_memory_mb}MB"
|
|
128
|
+
if self._effective_memory_mb is not None
|
|
129
|
+
else omit
|
|
130
|
+
),
|
|
131
|
+
overlay_size=(
|
|
132
|
+
f"{self._effective_storage_mb}MB"
|
|
133
|
+
if self._effective_storage_mb is not None
|
|
134
|
+
else omit
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
self._instance_id = instance.id
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
await self._client.instances.wait(
|
|
141
|
+
instance.id,
|
|
142
|
+
state="Running",
|
|
143
|
+
api_timeout="5m",
|
|
144
|
+
timeout=310,
|
|
145
|
+
)
|
|
146
|
+
await self._ensure_workdir()
|
|
147
|
+
await self.ensure_dirs(self._mount_targets(writable_only=True))
|
|
148
|
+
await self._upload_environment_dir_after_start()
|
|
149
|
+
except BaseException:
|
|
150
|
+
try:
|
|
151
|
+
await self._client.instances.delete(instance.id)
|
|
152
|
+
except Exception as cleanup_error:
|
|
153
|
+
self.logger.warning(
|
|
154
|
+
"Failed to delete Hypeman instance %s after startup failed: %s",
|
|
155
|
+
instance.id,
|
|
156
|
+
cleanup_error,
|
|
157
|
+
)
|
|
158
|
+
finally:
|
|
159
|
+
self._instance_id = None
|
|
160
|
+
raise
|
|
161
|
+
|
|
162
|
+
async def _ensure_workdir(self) -> None:
|
|
163
|
+
workdir = self.task_env_config.workdir
|
|
164
|
+
if workdir is None:
|
|
165
|
+
return
|
|
166
|
+
result = await self.exec(
|
|
167
|
+
f"mkdir -p {shlex.quote(workdir)}",
|
|
168
|
+
cwd="/",
|
|
169
|
+
user="root",
|
|
170
|
+
)
|
|
171
|
+
if result.return_code != 0:
|
|
172
|
+
raise RuntimeError(
|
|
173
|
+
f"Failed to create Hypeman workdir {workdir!r}: "
|
|
174
|
+
f"{result.stdout or 'no output'}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
async def _prepare_image(self, force_build: bool) -> str:
|
|
178
|
+
docker_image = self.task_env_config.docker_image
|
|
179
|
+
if should_use_prebuilt_docker_image(
|
|
180
|
+
self.environment_dir,
|
|
181
|
+
docker_image=docker_image,
|
|
182
|
+
force_build=force_build,
|
|
183
|
+
):
|
|
184
|
+
if docker_image is None:
|
|
185
|
+
raise RuntimeError("Prebuilt image selection requires docker_image.")
|
|
186
|
+
image = await self._client.images.create(
|
|
187
|
+
name=docker_image,
|
|
188
|
+
tags={_BUILD_TAG: self.environment_id},
|
|
189
|
+
)
|
|
190
|
+
return image.name
|
|
191
|
+
|
|
192
|
+
if not force_build:
|
|
193
|
+
cached_image = await self._cached_build_image()
|
|
194
|
+
if cached_image is not None:
|
|
195
|
+
return cached_image
|
|
196
|
+
|
|
197
|
+
return await self._build_image()
|
|
198
|
+
|
|
199
|
+
async def _cached_build_image(self) -> str | None:
|
|
200
|
+
builds = await self._client.builds.list(tags={_BUILD_TAG: self.environment_id})
|
|
201
|
+
for build in builds:
|
|
202
|
+
if build.status != "ready" or build.image_ref is None:
|
|
203
|
+
continue
|
|
204
|
+
try:
|
|
205
|
+
await self._client.images.get(build.image_ref)
|
|
206
|
+
except NotFoundError:
|
|
207
|
+
continue
|
|
208
|
+
return build.image_ref
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
async def _build_image(self) -> str:
|
|
212
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
213
|
+
archive_path = Path(temp_dir) / "environment.tar.gz"
|
|
214
|
+
await asyncio.to_thread(
|
|
215
|
+
self._write_build_archive,
|
|
216
|
+
self.environment_dir,
|
|
217
|
+
archive_path,
|
|
218
|
+
)
|
|
219
|
+
with archive_path.open("rb") as source:
|
|
220
|
+
build = await self._client.builds.create(
|
|
221
|
+
source=source,
|
|
222
|
+
tags=json.dumps({_BUILD_TAG: self.environment_id}),
|
|
223
|
+
timeout_seconds=int(self.task_env_config.build_timeout_sec),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
deadline = (
|
|
227
|
+
asyncio.get_running_loop().time() + self.task_env_config.build_timeout_sec
|
|
228
|
+
)
|
|
229
|
+
while build.status != "ready":
|
|
230
|
+
if build.status in _TERMINAL_BUILD_STATES:
|
|
231
|
+
raise SandboxBuildFailedError(
|
|
232
|
+
f"Hypeman build {build.id} ended in {build.status}: "
|
|
233
|
+
f"{build.error or 'no error details'}"
|
|
234
|
+
)
|
|
235
|
+
if asyncio.get_running_loop().time() >= deadline:
|
|
236
|
+
await self._client.builds.cancel(build.id)
|
|
237
|
+
raise TimeoutError(
|
|
238
|
+
f"Hypeman build {build.id} did not finish within "
|
|
239
|
+
f"{self.task_env_config.build_timeout_sec:g} seconds."
|
|
240
|
+
)
|
|
241
|
+
await asyncio.sleep(1)
|
|
242
|
+
build = await self._client.builds.get(build.id)
|
|
243
|
+
|
|
244
|
+
if build.image_ref is None:
|
|
245
|
+
raise SandboxBuildFailedError(
|
|
246
|
+
f"Hypeman build {build.id} completed without an image reference."
|
|
247
|
+
)
|
|
248
|
+
return build.image_ref
|
|
249
|
+
|
|
250
|
+
@staticmethod
|
|
251
|
+
def _write_build_archive(source_dir: Path, archive_path: Path) -> None:
|
|
252
|
+
dockerignore_path = source_dir / ".dockerignore"
|
|
253
|
+
dockerignore = (
|
|
254
|
+
GitIgnoreSpec.from_lines(dockerignore_path.read_text().splitlines())
|
|
255
|
+
if dockerignore_path.is_file()
|
|
256
|
+
else None
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
with tarfile.open(archive_path, "w:gz") as archive:
|
|
260
|
+
for path in sorted(source_dir.rglob("*")):
|
|
261
|
+
relative = path.relative_to(source_dir)
|
|
262
|
+
if {".git", "__pycache__"} & set(relative.parts):
|
|
263
|
+
continue
|
|
264
|
+
archive_name = relative.as_posix()
|
|
265
|
+
if (
|
|
266
|
+
dockerignore is not None
|
|
267
|
+
and archive_name not in {"Dockerfile", ".dockerignore"}
|
|
268
|
+
and dockerignore.match_file(
|
|
269
|
+
f"{archive_name}/" if path.is_dir() else archive_name
|
|
270
|
+
)
|
|
271
|
+
):
|
|
272
|
+
continue
|
|
273
|
+
archive.add(path, arcname=archive_name, recursive=False)
|
|
274
|
+
|
|
275
|
+
def _instance_name(self) -> str:
|
|
276
|
+
session = re.sub(r"[^a-z0-9]+", "-", self.session_id.lower()).strip("-")
|
|
277
|
+
if not session:
|
|
278
|
+
session = "session"
|
|
279
|
+
suffix = self.environment_id[:8]
|
|
280
|
+
return f"harbor-{session[:46]}-{suffix}".strip("-")
|
|
281
|
+
|
|
282
|
+
@override
|
|
283
|
+
async def stop(self, delete: bool) -> None:
|
|
284
|
+
if self._instance_id is None:
|
|
285
|
+
return
|
|
286
|
+
instance_id = self._instance_id
|
|
287
|
+
if delete:
|
|
288
|
+
await self._client.instances.delete(instance_id)
|
|
289
|
+
self._instance_id = None
|
|
290
|
+
else:
|
|
291
|
+
await self._client.instances.stop(instance_id)
|
|
292
|
+
|
|
293
|
+
def _require_instance(self) -> str:
|
|
294
|
+
if self._instance_id is None:
|
|
295
|
+
raise RuntimeError("Hypeman environment has not been started.")
|
|
296
|
+
return self._instance_id
|
|
297
|
+
|
|
298
|
+
@override
|
|
299
|
+
async def exec(
|
|
300
|
+
self,
|
|
301
|
+
command: str,
|
|
302
|
+
cwd: str | None = None,
|
|
303
|
+
env: dict[str, str] | None = None,
|
|
304
|
+
timeout_sec: int | None = None,
|
|
305
|
+
user: str | int | None = None,
|
|
306
|
+
) -> ExecResult:
|
|
307
|
+
user = self._resolve_user(user)
|
|
308
|
+
if user not in (None, "root", 0):
|
|
309
|
+
if isinstance(user, int):
|
|
310
|
+
user_name = f"$(getent passwd {user} | cut -d: -f1)"
|
|
311
|
+
else:
|
|
312
|
+
user_name = shlex.quote(user)
|
|
313
|
+
command = f"su {user_name} -s /bin/sh -c {shlex.quote(command)}"
|
|
314
|
+
|
|
315
|
+
result = await exec_async(
|
|
316
|
+
self._client,
|
|
317
|
+
self._require_instance(),
|
|
318
|
+
["/bin/bash", "-lc", command],
|
|
319
|
+
cwd=effective_exec_cwd(
|
|
320
|
+
cwd,
|
|
321
|
+
self.task_env_config.workdir,
|
|
322
|
+
self._dockerfile_workdir,
|
|
323
|
+
),
|
|
324
|
+
env=self._merge_env(env),
|
|
325
|
+
timeout=timeout_sec,
|
|
326
|
+
)
|
|
327
|
+
output = result.output.decode("utf-8", errors="replace")
|
|
328
|
+
callback = self._output_callback()
|
|
329
|
+
if callback is not None and output:
|
|
330
|
+
await callback(output, "stdout")
|
|
331
|
+
return ExecResult(stdout=output, stderr=None, return_code=result.exit_code)
|
|
332
|
+
|
|
333
|
+
@override
|
|
334
|
+
async def upload_file(self, source_path: Path | str, target_path: str) -> None:
|
|
335
|
+
await cp_to_instance_async(
|
|
336
|
+
self._client,
|
|
337
|
+
self._require_instance(),
|
|
338
|
+
source_path,
|
|
339
|
+
target_path,
|
|
340
|
+
archive=True,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
@override
|
|
344
|
+
async def upload_dir(self, source_dir: Path | str, target_dir: str) -> None:
|
|
345
|
+
await cp_to_instance_async(
|
|
346
|
+
self._client,
|
|
347
|
+
self._require_instance(),
|
|
348
|
+
source_dir,
|
|
349
|
+
target_dir,
|
|
350
|
+
archive=True,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
@override
|
|
354
|
+
async def download_file(self, source_path: str, target_path: Path | str) -> None:
|
|
355
|
+
target = Path(target_path)
|
|
356
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
357
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
358
|
+
temp = Path(temp_dir)
|
|
359
|
+
await cp_from_instance_async(
|
|
360
|
+
self._client,
|
|
361
|
+
self._require_instance(),
|
|
362
|
+
source_path,
|
|
363
|
+
temp,
|
|
364
|
+
archive=True,
|
|
365
|
+
)
|
|
366
|
+
downloaded = temp / PurePosixPath(source_path).name
|
|
367
|
+
if not downloaded.is_file():
|
|
368
|
+
raise RuntimeError(
|
|
369
|
+
f"Hypeman copy did not return the requested file {source_path!r}."
|
|
370
|
+
)
|
|
371
|
+
shutil.move(downloaded, target)
|
|
372
|
+
|
|
373
|
+
@override
|
|
374
|
+
async def download_dir(self, source_dir: str, target_dir: Path | str) -> None:
|
|
375
|
+
target = Path(target_dir)
|
|
376
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
377
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
378
|
+
temp = Path(temp_dir)
|
|
379
|
+
await cp_from_instance_async(
|
|
380
|
+
self._client,
|
|
381
|
+
self._require_instance(),
|
|
382
|
+
source_dir,
|
|
383
|
+
temp,
|
|
384
|
+
archive=True,
|
|
385
|
+
)
|
|
386
|
+
downloaded = temp / PurePosixPath(source_dir.rstrip("/")).name
|
|
387
|
+
if not downloaded.is_dir():
|
|
388
|
+
raise RuntimeError(
|
|
389
|
+
"Hypeman copy did not return the requested directory "
|
|
390
|
+
f"{source_dir!r}."
|
|
391
|
+
)
|
|
392
|
+
shutil.copytree(downloaded, target, dirs_exist_ok=True)
|