dockhand-cli 0.3.1__py3-none-any.whl
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.
- dockhand/__init__.py +335 -0
- dockhand/build.py +35 -0
- dockhand/client/__init__.py +38 -0
- dockhand/client/base.py +37 -0
- dockhand/client/local.py +45 -0
- dockhand/client/ssh.py +53 -0
- dockhand/config.py +355 -0
- dockhand/constants.py +2 -0
- dockhand/download.py +53 -0
- dockhand/error.py +10 -0
- dockhand/history.py +171 -0
- dockhand/manage.py +459 -0
- dockhand/queue.py +174 -0
- dockhand/resubmit.py +49 -0
- dockhand/submit.py +163 -0
- dockhand/sync.py +50 -0
- dockhand/tagging.py +48 -0
- dockhand/transport.py +212 -0
- dockhand/tunnel.py +62 -0
- dockhand/volumes.py +149 -0
- dockhand_cli-0.3.1.dist-info/METADATA +478 -0
- dockhand_cli-0.3.1.dist-info/RECORD +25 -0
- dockhand_cli-0.3.1.dist-info/WHEEL +4 -0
- dockhand_cli-0.3.1.dist-info/entry_points.txt +2 -0
- dockhand_cli-0.3.1.dist-info/licenses/LICENSE +7 -0
dockhand/config.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import json
|
|
3
|
+
from hashlib import sha256
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from dockhand.constants import CONFIG_FILENAME, HISTORY_FILENAME
|
|
10
|
+
from dockhand.error import error_and_exit
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclasses.dataclass
|
|
14
|
+
class DockerVolumesConfig:
|
|
15
|
+
hostpath: str
|
|
16
|
+
containerpath: str
|
|
17
|
+
permissions: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclasses.dataclass
|
|
21
|
+
class DockerResubmitConfig:
|
|
22
|
+
container_id: str | None
|
|
23
|
+
commands: List[str] | None
|
|
24
|
+
imagename: str | None
|
|
25
|
+
gpus: str | None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
CODE_DELIVERY_MODES = ("mount", "bake")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclasses.dataclass
|
|
32
|
+
class DockerConfig:
|
|
33
|
+
dockerfile: str
|
|
34
|
+
imagename: str
|
|
35
|
+
volumes: list[DockerVolumesConfig]
|
|
36
|
+
ports: list[str]
|
|
37
|
+
gpus: str
|
|
38
|
+
containerworkdir: str
|
|
39
|
+
preserve_paths: list[str] = dataclasses.field(default_factory=list)
|
|
40
|
+
# None means "derive from queue.enabled" (bake when queued, mount otherwise).
|
|
41
|
+
code_delivery: str | None = None
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def load(cls, config: dict):
|
|
45
|
+
if "docker" not in config:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
docker = config["docker"]
|
|
49
|
+
if "dockerfile" not in docker:
|
|
50
|
+
error_and_exit('"dockerfile" not found in docker config.')
|
|
51
|
+
|
|
52
|
+
if "volumes" not in docker:
|
|
53
|
+
error_and_exit('"volumes" not found in docker config.')
|
|
54
|
+
|
|
55
|
+
if "imagename" not in docker:
|
|
56
|
+
error_and_exit('"imagename" not found in docker config.')
|
|
57
|
+
|
|
58
|
+
if "gpus" not in docker:
|
|
59
|
+
docker["gpus"] = None
|
|
60
|
+
|
|
61
|
+
if "ports" not in docker:
|
|
62
|
+
docker["ports"] = None
|
|
63
|
+
|
|
64
|
+
if "containerworkdir" not in docker:
|
|
65
|
+
docker["containerworkdir"] = "/"
|
|
66
|
+
|
|
67
|
+
if "preserve_paths" not in docker:
|
|
68
|
+
docker["preserve_paths"] = []
|
|
69
|
+
|
|
70
|
+
code_delivery = docker.get("code_delivery")
|
|
71
|
+
if code_delivery is not None and code_delivery not in CODE_DELIVERY_MODES:
|
|
72
|
+
error_and_exit(
|
|
73
|
+
f"Invalid value for code_delivery in docker config. Expected one of {CODE_DELIVERY_MODES} "
|
|
74
|
+
f"but got {code_delivery!r}."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Only pass fields that DockerConfig expects
|
|
78
|
+
return cls(
|
|
79
|
+
dockerfile=docker["dockerfile"],
|
|
80
|
+
imagename=docker["imagename"],
|
|
81
|
+
volumes=docker["volumes"],
|
|
82
|
+
ports=docker["ports"],
|
|
83
|
+
gpus=docker["gpus"],
|
|
84
|
+
containerworkdir=docker["containerworkdir"],
|
|
85
|
+
preserve_paths=docker["preserve_paths"],
|
|
86
|
+
code_delivery=code_delivery,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def resolve_code_delivery(self, queue_enabled: bool) -> str:
|
|
90
|
+
"""Resolve the effective delivery mode, applying the queue-derived default."""
|
|
91
|
+
if self.code_delivery is not None:
|
|
92
|
+
return self.code_delivery
|
|
93
|
+
return "bake" if queue_enabled else "mount"
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def validate(cls, config: dict) -> dict:
|
|
97
|
+
if not isinstance(config, dict):
|
|
98
|
+
error_and_exit(f"Invalid type for ssh option in config. Expected dictionary but got {type(config)}.")
|
|
99
|
+
|
|
100
|
+
output = {}
|
|
101
|
+
|
|
102
|
+
dockerfile = config.get("dockerfile")
|
|
103
|
+
if dockerfile is not None:
|
|
104
|
+
if not isinstance(dockerfile, str):
|
|
105
|
+
error_and_exit(f"Invalid type for dockerfile. Expected string, got {type(dockerfile).__name__}.")
|
|
106
|
+
output["dockerfile"] = dockerfile
|
|
107
|
+
|
|
108
|
+
imagename = config.get("imagename")
|
|
109
|
+
if imagename is not None:
|
|
110
|
+
if not isinstance(imagename, str):
|
|
111
|
+
error_and_exit(
|
|
112
|
+
f"Invalid type for compose_file option in docker config. Expected string but got {type(imagename)}."
|
|
113
|
+
)
|
|
114
|
+
output["imagename"] = imagename
|
|
115
|
+
|
|
116
|
+
volumes = config.get("volumes")
|
|
117
|
+
if volumes is not None:
|
|
118
|
+
if not isinstance(volumes, str):
|
|
119
|
+
error_and_exit(
|
|
120
|
+
f"Invalid type for compose_file option in docker config. Expected string but got {type(volumes)}."
|
|
121
|
+
)
|
|
122
|
+
output["volumes"] = volumes
|
|
123
|
+
|
|
124
|
+
ports = config.get("ports")
|
|
125
|
+
if ports is not None:
|
|
126
|
+
if not isinstance(ports, str):
|
|
127
|
+
error_and_exit(
|
|
128
|
+
f"Invalid type for compose_file option in docker config. Expected string but got {type(ports)}."
|
|
129
|
+
)
|
|
130
|
+
output["ports"] = ports
|
|
131
|
+
|
|
132
|
+
gpus = config.get("gpus")
|
|
133
|
+
if gpus is not None:
|
|
134
|
+
if not isinstance(gpus, str):
|
|
135
|
+
error_and_exit(
|
|
136
|
+
f"Invalid type for gpus option in docker config. Expected string but got {type(gpus).__name__}."
|
|
137
|
+
)
|
|
138
|
+
output["gpus"] = gpus
|
|
139
|
+
|
|
140
|
+
preserve_paths = config.get("preserve_paths")
|
|
141
|
+
if preserve_paths is not None:
|
|
142
|
+
if not isinstance(preserve_paths, list) or not all(isinstance(p, str) for p in preserve_paths):
|
|
143
|
+
error_and_exit(
|
|
144
|
+
"Invalid value for preserve_paths option in docker config. Expected a list of strings but got "
|
|
145
|
+
f"{preserve_paths!r}."
|
|
146
|
+
)
|
|
147
|
+
output["preserve_paths"] = preserve_paths
|
|
148
|
+
|
|
149
|
+
return output
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclasses.dataclass
|
|
153
|
+
class QueueConfig:
|
|
154
|
+
enabled: bool = False
|
|
155
|
+
tool: str = "task_spooler"
|
|
156
|
+
slots: int = 1
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def load(cls, config: dict):
|
|
160
|
+
queue = config.get("queue", {})
|
|
161
|
+
if not isinstance(queue, dict):
|
|
162
|
+
error_and_exit(f"Invalid type for queue option in config. Expected dictionary but got {type(queue)}.")
|
|
163
|
+
enabled = queue.get("enabled", False)
|
|
164
|
+
tool = queue.get("tool", "task_spooler")
|
|
165
|
+
slots = cls._load_slots(queue, config.get("docker") or {})
|
|
166
|
+
return cls(enabled=enabled, tool=tool, slots=slots)
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _load_slots(queue: dict, docker: dict) -> int:
|
|
170
|
+
slots = queue.get("slots")
|
|
171
|
+
if slots is None and isinstance(docker, dict) and "slots" in docker:
|
|
172
|
+
# Back-compat: slots used to live under the docker block.
|
|
173
|
+
typer.echo(
|
|
174
|
+
"Warning: 'docker.slots' is deprecated — move it to 'queue.slots'.",
|
|
175
|
+
err=True,
|
|
176
|
+
)
|
|
177
|
+
slots = docker["slots"]
|
|
178
|
+
if slots is None:
|
|
179
|
+
return 1
|
|
180
|
+
if not isinstance(slots, int) or isinstance(slots, bool) or slots < 1:
|
|
181
|
+
error_and_exit(
|
|
182
|
+
f"Invalid value for slots option in queue config. Expected a positive integer but got {slots!r}."
|
|
183
|
+
)
|
|
184
|
+
return slots
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclasses.dataclass
|
|
188
|
+
class SSHConfig:
|
|
189
|
+
hostname: str
|
|
190
|
+
user: str
|
|
191
|
+
identityfile: str
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def load(cls, config: dict):
|
|
195
|
+
if "ssh" not in config:
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
ssh = config["ssh"]
|
|
199
|
+
ssh = SSHConfig.validate(ssh)
|
|
200
|
+
|
|
201
|
+
if "hostname" not in ssh:
|
|
202
|
+
error_and_exit('"hostname" not found in SSH config.')
|
|
203
|
+
|
|
204
|
+
if "user" not in ssh:
|
|
205
|
+
error_and_exit('"user" not found in SSH config.')
|
|
206
|
+
|
|
207
|
+
if "identityfile" not in ssh:
|
|
208
|
+
error_and_exit('"identityfile" not found in SSH config')
|
|
209
|
+
|
|
210
|
+
return cls(**ssh)
|
|
211
|
+
|
|
212
|
+
@classmethod
|
|
213
|
+
def validate(cls, config: dict) -> dict:
|
|
214
|
+
if not isinstance(config, dict):
|
|
215
|
+
error_and_exit(f"Invalid type for ssh option in config. Expected dictionary but got {type(config)}.")
|
|
216
|
+
|
|
217
|
+
output = {}
|
|
218
|
+
|
|
219
|
+
hostname = config.get("hostname")
|
|
220
|
+
if hostname is not None:
|
|
221
|
+
if not isinstance(hostname, str):
|
|
222
|
+
error_and_exit(f"Invalid type for host option in ssh config. Expected string but got {type(hostname)}.")
|
|
223
|
+
output["hostname"] = hostname
|
|
224
|
+
|
|
225
|
+
user = config.get("user")
|
|
226
|
+
if user is not None:
|
|
227
|
+
if not isinstance(user, str):
|
|
228
|
+
error_and_exit(f"Invalid type for user option in ssh config. Expected string but got {type(user)}.")
|
|
229
|
+
output["user"] = user
|
|
230
|
+
|
|
231
|
+
identityfile = config.get("identityfile")
|
|
232
|
+
if identityfile is not None:
|
|
233
|
+
if not isinstance(identityfile, str):
|
|
234
|
+
error_and_exit(
|
|
235
|
+
f"Invalid type for identityfile option in ssh config. Expected string but got {type(identityfile)}."
|
|
236
|
+
)
|
|
237
|
+
output["identityfile"] = str(Path(identityfile).expanduser())
|
|
238
|
+
|
|
239
|
+
return output
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclasses.dataclass
|
|
243
|
+
class CLIConfig:
|
|
244
|
+
history_path: Path
|
|
245
|
+
project_root: Path
|
|
246
|
+
remote_path: str
|
|
247
|
+
sync: bool
|
|
248
|
+
profiles: dict | None
|
|
249
|
+
ssh: SSHConfig | None
|
|
250
|
+
docker: DockerConfig | None
|
|
251
|
+
queue: QueueConfig = dataclasses.field(default_factory=QueueConfig)
|
|
252
|
+
|
|
253
|
+
@classmethod
|
|
254
|
+
def load(cls):
|
|
255
|
+
project_root = cls.get_project_root()
|
|
256
|
+
|
|
257
|
+
git_path = project_root / ".git"
|
|
258
|
+
if not git_path.exists():
|
|
259
|
+
error_and_exit(f"Could not find git repository at '{git_path}'.")
|
|
260
|
+
|
|
261
|
+
path = project_root / CONFIG_FILENAME
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
config = json.loads(path.read_text())
|
|
265
|
+
except json.JSONDecodeError as e:
|
|
266
|
+
error_and_exit(f"Error while parsing config file at '{path}':\n{e}")
|
|
267
|
+
|
|
268
|
+
if not isinstance(config, dict):
|
|
269
|
+
error_and_exit(f"Invalid type for config. Expected dictionary but got {type(config)}.")
|
|
270
|
+
|
|
271
|
+
profiles = config.get("profiles")
|
|
272
|
+
if profiles is not None and not isinstance(profiles, dict):
|
|
273
|
+
error_and_exit(f"Invalid type for profiles option in config. Expected dictionary but got {type(profiles)}.")
|
|
274
|
+
|
|
275
|
+
history_path = cls.load_history_path(config, project_root)
|
|
276
|
+
remote_path = cls.load_remote_path(config, project_root)
|
|
277
|
+
sync = config.get("sync", True)
|
|
278
|
+
ssh = SSHConfig.load(config)
|
|
279
|
+
docker = DockerConfig.load(config)
|
|
280
|
+
queue = QueueConfig.load(config)
|
|
281
|
+
|
|
282
|
+
return cls(
|
|
283
|
+
history_path=history_path,
|
|
284
|
+
profiles=profiles,
|
|
285
|
+
project_root=project_root,
|
|
286
|
+
remote_path=remote_path,
|
|
287
|
+
sync=sync,
|
|
288
|
+
ssh=ssh,
|
|
289
|
+
docker=docker,
|
|
290
|
+
queue=queue,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
@classmethod
|
|
294
|
+
def get_project_root(cls) -> Path:
|
|
295
|
+
"""Assume that config file exist in the project root and use that to get the project root."""
|
|
296
|
+
root = Path("/")
|
|
297
|
+
current_path = Path.cwd()
|
|
298
|
+
while current_path != root:
|
|
299
|
+
if (current_path / CONFIG_FILENAME).exists():
|
|
300
|
+
return current_path
|
|
301
|
+
current_path = current_path.parent
|
|
302
|
+
|
|
303
|
+
if (root / CONFIG_FILENAME).exists():
|
|
304
|
+
return root
|
|
305
|
+
|
|
306
|
+
error_and_exit(
|
|
307
|
+
f"Could not find project root. Make sure that '{CONFIG_FILENAME}' exists in the root of the project."
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
@classmethod
|
|
311
|
+
def load_history_path(cls, config: dict, project_root: Path) -> Path:
|
|
312
|
+
if "history_path" in config:
|
|
313
|
+
history_path = config["history_path"]
|
|
314
|
+
if not isinstance(history_path, str):
|
|
315
|
+
error_and_exit(
|
|
316
|
+
f"Invalid type for history_path option in config. Expected string but got {type(history_path)}."
|
|
317
|
+
)
|
|
318
|
+
return Path(history_path)
|
|
319
|
+
return project_root / HISTORY_FILENAME
|
|
320
|
+
|
|
321
|
+
@classmethod
|
|
322
|
+
def load_remote_path(cls, config: dict, project_root: Path) -> str:
|
|
323
|
+
if "remote_path" in config:
|
|
324
|
+
return config["remote_path"]
|
|
325
|
+
|
|
326
|
+
name = project_root.name
|
|
327
|
+
hash = sha256(str(project_root).encode()).hexdigest()[:8]
|
|
328
|
+
return f"~/{name}-{hash}"
|
|
329
|
+
|
|
330
|
+
def check_ssh(self, msg: str = "SSH configuration is required for this command."):
|
|
331
|
+
if self.ssh is None:
|
|
332
|
+
error_and_exit(msg)
|
|
333
|
+
|
|
334
|
+
def check_docker(self, msg: str = "Docker configuration is required for this command"):
|
|
335
|
+
if self.docker is None:
|
|
336
|
+
error_and_exit(msg)
|
|
337
|
+
|
|
338
|
+
def load_profile(self, name: str):
|
|
339
|
+
if name not in self.profiles:
|
|
340
|
+
error_and_exit(f"Profile '{name}' not found in config.")
|
|
341
|
+
|
|
342
|
+
profile = self.profiles[name]
|
|
343
|
+
|
|
344
|
+
if "history_path" in profile:
|
|
345
|
+
self.history_path = CLIConfig.load_history_path(profile, self.project_root)
|
|
346
|
+
|
|
347
|
+
if "remote_path" in profile:
|
|
348
|
+
self.remote_path = profile["remote_path"]
|
|
349
|
+
|
|
350
|
+
if "ssh" in profile:
|
|
351
|
+
ssh = SSHConfig.validate(profile["ssh"])
|
|
352
|
+
self.ssh = dataclasses.replace(self.ssh, **ssh)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
cli_config = CLIConfig.load()
|
dockhand/constants.py
ADDED
dockhand/download.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Download files from docker volumes."""
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
6
|
+
|
|
7
|
+
from dockhand.config import DockerConfig, cli_config
|
|
8
|
+
from dockhand.error import error_and_exit
|
|
9
|
+
from dockhand.volumes import _resolve_to_host
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def execute_download(config: DockerConfig, path: str, local_path: str | None = None):
|
|
13
|
+
"""Download a file from a docker volume by its workdir-relative path."""
|
|
14
|
+
result = _resolve_to_host(path, config)
|
|
15
|
+
if result is None:
|
|
16
|
+
error_and_exit(f"Path '{path}' does not match any configured docker volume.")
|
|
17
|
+
|
|
18
|
+
host_path, _ = result
|
|
19
|
+
ssh = cli_config.ssh
|
|
20
|
+
|
|
21
|
+
# If no local_path provided, mirror the remote path structure locally.
|
|
22
|
+
# For directory downloads (trailing slash), use the directory itself as the destination
|
|
23
|
+
# so rsync places contents at reports/figures/ rather than reports/.
|
|
24
|
+
# For file downloads, use the parent directory so rsync places the file correctly.
|
|
25
|
+
if local_path is None:
|
|
26
|
+
if path.endswith("/"):
|
|
27
|
+
dest = cli_config.project_root / Path(path.rstrip("/"))
|
|
28
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
local_path = str(dest)
|
|
30
|
+
else:
|
|
31
|
+
parent_dir = cli_config.project_root / Path(path).parent
|
|
32
|
+
parent_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
local_path = str(parent_dir)
|
|
34
|
+
|
|
35
|
+
with Progress(
|
|
36
|
+
SpinnerColumn(),
|
|
37
|
+
TextColumn("[progress.description]{task.description}"),
|
|
38
|
+
) as progress:
|
|
39
|
+
task = progress.add_task(description="Downloading", total=None)
|
|
40
|
+
progress.start()
|
|
41
|
+
try:
|
|
42
|
+
command = [
|
|
43
|
+
"rsync",
|
|
44
|
+
"-avz",
|
|
45
|
+
"-e",
|
|
46
|
+
f"ssh -i {ssh.identityfile}",
|
|
47
|
+
f"{ssh.user}@{ssh.hostname}:{host_path}",
|
|
48
|
+
local_path,
|
|
49
|
+
]
|
|
50
|
+
subprocess.run(command, check=True, capture_output=True)
|
|
51
|
+
except subprocess.CalledProcessError as e:
|
|
52
|
+
error_and_exit(f"Download failed:\n{e.stderr.decode()}")
|
|
53
|
+
progress.update(task, completed=True)
|
dockhand/error.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from rich import print as rich_print
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def error_and_exit(message: str, code: int = 1):
|
|
8
|
+
panel = Panel(message, border_style="red", title="Error", title_align="left", highlight=True)
|
|
9
|
+
rich_print(panel)
|
|
10
|
+
sys.exit(code)
|
dockhand/history.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Docker container run history management and tracking."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from dockhand.config import DockerConfig
|
|
15
|
+
from dockhand.constants import HISTORY_FILENAME
|
|
16
|
+
|
|
17
|
+
DOCKER_HISTORY_FILE = Path(".dockhand_history.json")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_history() -> list[dict]:
|
|
21
|
+
"""Load container run history from disk."""
|
|
22
|
+
# path = DOCKER_HISTORY_FILE
|
|
23
|
+
path = Path(HISTORY_FILENAME)
|
|
24
|
+
if not path.exists():
|
|
25
|
+
return []
|
|
26
|
+
return json.loads(path.read_text())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def save_history(history: list[dict]):
|
|
30
|
+
"""Save container run history to disk."""
|
|
31
|
+
# path = DOCKER_HISTORY_FILE
|
|
32
|
+
path = Path(HISTORY_FILENAME)
|
|
33
|
+
path.write_text(json.dumps(history))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _next_local_id(history: list[dict]) -> int:
|
|
37
|
+
if not history:
|
|
38
|
+
return 1
|
|
39
|
+
return max((e.get("local_id", 0) for e in history), default=0) + 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def reserve_local_id() -> int:
|
|
43
|
+
"""Peek the next local job ID without persisting it.
|
|
44
|
+
|
|
45
|
+
Submitting needs the ID before the job runs (e.g. to name the container), so it
|
|
46
|
+
is reserved here and passed to :func:`add_to_history` once the job has started.
|
|
47
|
+
"""
|
|
48
|
+
return _next_local_id(load_history())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def add_to_history(
|
|
52
|
+
config: DockerConfig,
|
|
53
|
+
commands: List[str],
|
|
54
|
+
*,
|
|
55
|
+
local_id: int,
|
|
56
|
+
handle: dict,
|
|
57
|
+
image_ref: str | None = None,
|
|
58
|
+
branch: str | None = None,
|
|
59
|
+
ports: list[str] | None = None,
|
|
60
|
+
host: str | None = None,
|
|
61
|
+
) -> int:
|
|
62
|
+
"""Add a started job to the history file. Returns the local job ID.
|
|
63
|
+
|
|
64
|
+
``handle`` carries the transport-specific job handle (e.g. ``transport`` name and
|
|
65
|
+
a ``ts_job_id`` or container ``handle``) and is merged into the entry. ``image_ref``
|
|
66
|
+
is the exact image that ran (a resolved baked tag, or the base image name for mount
|
|
67
|
+
delivery) so the job can be reproduced verbatim on resubmit.
|
|
68
|
+
"""
|
|
69
|
+
history = load_history()
|
|
70
|
+
_d = {
|
|
71
|
+
"gpus": config.gpus,
|
|
72
|
+
"volumes": config.volumes,
|
|
73
|
+
"imagename": config.imagename,
|
|
74
|
+
"commands": commands,
|
|
75
|
+
"ports": ports,
|
|
76
|
+
}
|
|
77
|
+
if image_ref is not None:
|
|
78
|
+
_d["image_ref"] = image_ref
|
|
79
|
+
if branch is not None:
|
|
80
|
+
_d["branch"] = branch
|
|
81
|
+
entry = {
|
|
82
|
+
"local_id": local_id,
|
|
83
|
+
"timestamp": time.time(),
|
|
84
|
+
"config": _d,
|
|
85
|
+
**handle,
|
|
86
|
+
}
|
|
87
|
+
if host is not None:
|
|
88
|
+
entry["host"] = host
|
|
89
|
+
history.append(entry)
|
|
90
|
+
save_history(history)
|
|
91
|
+
return local_id
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_history_entry(local_id: int) -> dict | None:
|
|
95
|
+
"""Look up a history entry by local job ID."""
|
|
96
|
+
history = load_history()
|
|
97
|
+
for entry in reversed(history):
|
|
98
|
+
if entry.get("local_id") == local_id:
|
|
99
|
+
return entry
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def mark_stopped(local_id: int):
|
|
104
|
+
"""Flag a history entry as user-stopped, so ``jobs`` can show it distinctly."""
|
|
105
|
+
history = load_history()
|
|
106
|
+
for entry in history:
|
|
107
|
+
if entry.get("local_id") == local_id:
|
|
108
|
+
entry["stopped"] = True
|
|
109
|
+
save_history(history)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def mark_job_time(local_id: int, *, started_at: float | None = None, ended_at: float | None = None):
|
|
113
|
+
"""Record start/end timestamps for a job, without overwriting ones already set.
|
|
114
|
+
|
|
115
|
+
Neither the queue nor docker exposes exact start/end timestamps cheaply, so these
|
|
116
|
+
are stamped lazily the first time a command happens to observe the job in that
|
|
117
|
+
state (see ``manage.py``).
|
|
118
|
+
"""
|
|
119
|
+
history = load_history()
|
|
120
|
+
changed = False
|
|
121
|
+
for entry in history:
|
|
122
|
+
if entry.get("local_id") != local_id:
|
|
123
|
+
continue
|
|
124
|
+
if started_at is not None and "started_at" not in entry:
|
|
125
|
+
entry["started_at"] = started_at
|
|
126
|
+
changed = True
|
|
127
|
+
if ended_at is not None and "ended_at" not in entry:
|
|
128
|
+
entry["ended_at"] = ended_at
|
|
129
|
+
changed = True
|
|
130
|
+
if changed:
|
|
131
|
+
save_history(history)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def execute_history(config: DockerConfig):
|
|
135
|
+
"""Show history of past Docker runs."""
|
|
136
|
+
history_file = Path(HISTORY_FILENAME)
|
|
137
|
+
if not history_file.exists():
|
|
138
|
+
typer.echo(f"No history found in '{history_file}'. You might not have submitted any jobs yet.")
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
history = load_history()
|
|
142
|
+
|
|
143
|
+
table = Table(title="Docker Run History", show_lines=True)
|
|
144
|
+
table.add_column("Job ID", justify="right", style="bold")
|
|
145
|
+
table.add_column("Host")
|
|
146
|
+
table.add_column("Timestamp")
|
|
147
|
+
table.add_column("Branch")
|
|
148
|
+
table.add_column("GPU(s)")
|
|
149
|
+
table.add_column("Volume(s)")
|
|
150
|
+
table.add_column("Imagename")
|
|
151
|
+
table.add_column("Commands")
|
|
152
|
+
|
|
153
|
+
for entry in history:
|
|
154
|
+
local_id = str(entry.get("local_id", "-"))
|
|
155
|
+
host = entry.get("host") or "-"
|
|
156
|
+
timestamp = datetime.fromtimestamp(entry["timestamp"]).strftime("%Y-%m-%d %H:%M:%S")
|
|
157
|
+
_config = entry["config"]
|
|
158
|
+
branch = _config.get("branch") or "-"
|
|
159
|
+
gpus = _config["gpus"] if _config["gpus"] else "-"
|
|
160
|
+
volumes = (
|
|
161
|
+
"\n".join(
|
|
162
|
+
[f"{v['hostpath']}:{v['containerpath']}:{v['permissions']}" for v in (_config.get("volumes") or [])]
|
|
163
|
+
)
|
|
164
|
+
or "-"
|
|
165
|
+
)
|
|
166
|
+
imagename = _config["imagename"]
|
|
167
|
+
cmds = _config.get("commands") or _config.get("arguments") or []
|
|
168
|
+
commands_str = " ".join(cmds)
|
|
169
|
+
table.add_row(local_id, host, timestamp, branch, gpus, volumes, imagename, Text(commands_str))
|
|
170
|
+
|
|
171
|
+
Console().print(table)
|