simstack 0.2.0__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.
- simstack/__init__.py +0 -0
- simstack/_version.py +34 -0
- simstack/core/__init__.py +0 -0
- simstack/core/artifacts.py +447 -0
- simstack/core/asnyc_helper.py +14 -0
- simstack/core/config_file.py +60 -0
- simstack/core/context.py +167 -0
- simstack/core/data.py +139 -0
- simstack/core/definitions.py +21 -0
- simstack/core/engine.py +116 -0
- simstack/core/find_simstack_modules.py +43 -0
- simstack/core/hash.py +177 -0
- simstack/core/model_table.py +199 -0
- simstack/core/node.py +911 -0
- simstack/core/node_runner.py +254 -0
- simstack/core/node_table.py +380 -0
- simstack/core/recompute_artifacts.py +120 -0
- simstack/core/reset_db.py +12 -0
- simstack/core/resources.py +101 -0
- simstack/core/run_node.py +69 -0
- simstack/core/runner.py +435 -0
- simstack/core/simstack_result.py +41 -0
- simstack/core/submit_node.py +143 -0
- simstack/core/task_id.py +22 -0
- simstack/data/__init__.py +0 -0
- simstack/methods/__init__.py +0 -0
- simstack/methods/get_file.py +10 -0
- simstack/methods/multiple_executor.py +307 -0
- simstack/models/__init__.py +37 -0
- simstack/models/array_list.py +48 -0
- simstack/models/array_storage.py +54 -0
- simstack/models/artifact_models.py +127 -0
- simstack/models/base_types.py +208 -0
- simstack/models/charts_artifact.py +411 -0
- simstack/models/dataset.py +432 -0
- simstack/models/dataset_metadata.py +303 -0
- simstack/models/file_instance.py +77 -0
- simstack/models/file_list.py +321 -0
- simstack/models/files.py +229 -0
- simstack/models/log_entry_model.py +37 -0
- simstack/models/models.py +40 -0
- simstack/models/node_registry.py +105 -0
- simstack/models/pandas_model.py +210 -0
- simstack/models/parameters.py +388 -0
- simstack/models/pickle_models.py +311 -0
- simstack/models/runner_model.py +39 -0
- simstack/models/simple_table.py +32 -0
- simstack/models/simstack_model.py +97 -0
- simstack/models/slurm_info.py +17 -0
- simstack/models/table_artifact.py +87 -0
- simstack/util/__init__.py +0 -0
- simstack/util/async_zip_utils.py +18 -0
- simstack/util/cleaned_json_schema.py +20 -0
- simstack/util/config_reader.py +273 -0
- simstack/util/custom_model_dump.py +75 -0
- simstack/util/db.py +457 -0
- simstack/util/db_logger.py +117 -0
- simstack/util/default_from_dict.py +57 -0
- simstack/util/directory_iterator.py +216 -0
- simstack/util/file_hashing.py +68 -0
- simstack/util/files.py +396 -0
- simstack/util/find_python_files.py +33 -0
- simstack/util/generate_ui_schema.py +80 -0
- simstack/util/geometry_utils.py +118 -0
- simstack/util/get_module_path.py +26 -0
- simstack/util/git_pull.py +65 -0
- simstack/util/git_repository_status.py +93 -0
- simstack/util/import_module.py +65 -0
- simstack/util/importer.py +163 -0
- simstack/util/make_graph.py +107 -0
- simstack/util/make_table.py +827 -0
- simstack/util/minimal_route_finder.py +72 -0
- simstack/util/mock_db.py +144 -0
- simstack/util/module_path_checker.py +38 -0
- simstack/util/path_manager.py +234 -0
- simstack/util/project_root_finder.py +36 -0
- simstack/util/queue_watcher.py +262 -0
- simstack/util/route_finder.py +74 -0
- simstack/util/runner_util.py +162 -0
- simstack/util/safe_code_executor.py +194 -0
- simstack/util/sample_config.py +98 -0
- simstack/util/sensitive_content_filter.py +48 -0
- simstack/util/setup_logging.py +49 -0
- simstack/util/submit_to_watchdog.py +106 -0
- simstack/util/ui_tools.py +213 -0
- simstack-0.2.0.dist-info/METADATA +361 -0
- simstack-0.2.0.dist-info/RECORD +91 -0
- simstack-0.2.0.dist-info/WHEEL +5 -0
- simstack-0.2.0.dist-info/entry_points.txt +10 -0
- simstack-0.2.0.dist-info/licenses/LICENSE +21 -0
- simstack-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_spec(
|
|
14
|
+
cmd_file: Path
|
|
15
|
+
) -> Tuple[Optional[str], Dict[str, str], Optional[str], Optional[int]]:
|
|
16
|
+
cwd: Optional[str] = None
|
|
17
|
+
env: Dict[str, str] = {}
|
|
18
|
+
command: Optional[str] = None
|
|
19
|
+
timeout: Optional[int] = None
|
|
20
|
+
|
|
21
|
+
with cmd_file.open("r", encoding="utf-8") as f:
|
|
22
|
+
for raw in f:
|
|
23
|
+
line = raw.strip()
|
|
24
|
+
if not line:
|
|
25
|
+
continue
|
|
26
|
+
if line.startswith("CWD="):
|
|
27
|
+
cwd = line[len("CWD=") :]
|
|
28
|
+
elif line.startswith("ENV[") and "]=" in line:
|
|
29
|
+
key = line[len("ENV[") : line.index("]=", len("ENV["))]
|
|
30
|
+
val = line.split("]=", 1)[1]
|
|
31
|
+
env[key] = val
|
|
32
|
+
elif line.startswith("CMD="):
|
|
33
|
+
command = line[len("CMD=") :]
|
|
34
|
+
elif line.startswith("TIMEOUT="):
|
|
35
|
+
try:
|
|
36
|
+
timeout = int(line[len("TIMEOUT=") :])
|
|
37
|
+
except ValueError:
|
|
38
|
+
timeout = None
|
|
39
|
+
return cwd, env, command, timeout
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run_command(
|
|
43
|
+
command: str,
|
|
44
|
+
cwd: Optional[str],
|
|
45
|
+
env: Dict[str, str],
|
|
46
|
+
timeout: Optional[int],
|
|
47
|
+
out_file: Path,
|
|
48
|
+
err_file: Path,
|
|
49
|
+
) -> int:
|
|
50
|
+
proc_env = os.environ.copy()
|
|
51
|
+
proc_env.update(env)
|
|
52
|
+
with out_file.open("w", encoding="utf-8") as out, err_file.open(
|
|
53
|
+
"w", encoding="utf-8"
|
|
54
|
+
) as err:
|
|
55
|
+
try:
|
|
56
|
+
proc = subprocess.run(
|
|
57
|
+
command,
|
|
58
|
+
shell=True,
|
|
59
|
+
cwd=cwd if cwd else None,
|
|
60
|
+
env=proc_env,
|
|
61
|
+
stdout=out,
|
|
62
|
+
stderr=err,
|
|
63
|
+
text=True,
|
|
64
|
+
timeout=timeout if (timeout and timeout > 0) else None,
|
|
65
|
+
check=False,
|
|
66
|
+
)
|
|
67
|
+
return proc.returncode
|
|
68
|
+
except subprocess.TimeoutExpired:
|
|
69
|
+
err.write("\n[watcher] Command timed out\n")
|
|
70
|
+
return 124
|
|
71
|
+
except Exception as e:
|
|
72
|
+
err.write(f"\n[watcher] Execution error: {e}\n")
|
|
73
|
+
return 1
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def process_job_from_claim(base_stem: Path, claimed_by_lock: bool) -> None:
|
|
77
|
+
"""
|
|
78
|
+
Process a job using base path (without suffix). If claimed_by_lock is True, we did not
|
|
79
|
+
rename .ready; we will remove it at the end. Otherwise, we operate on .claim and remove it.
|
|
80
|
+
"""
|
|
81
|
+
cmd_file = base_stem.with_suffix(".cmd")
|
|
82
|
+
ready_file = base_stem.with_suffix(".ready")
|
|
83
|
+
claim_file = base_stem.with_suffix(".claim")
|
|
84
|
+
lock_file = base_stem.with_suffix(".lock")
|
|
85
|
+
out_file = base_stem.with_suffix(".stdout")
|
|
86
|
+
err_file = base_stem.with_suffix(".stderr")
|
|
87
|
+
exit_file = base_stem.with_suffix(".exit")
|
|
88
|
+
done_file = base_stem.with_suffix(".done")
|
|
89
|
+
fail_file = base_stem.with_suffix(".fail")
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
if not cmd_file.exists():
|
|
93
|
+
fail_file.touch()
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
cwd, env, command, timeout = parse_spec(cmd_file)
|
|
97
|
+
if not command:
|
|
98
|
+
err_file.write_text("[watcher] CMD missing in spec\n", encoding="utf-8")
|
|
99
|
+
exit_file.write_text("2\n", encoding="utf-8")
|
|
100
|
+
fail_file.touch()
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
rc = run_command(command, cwd, env, timeout, out_file, err_file)
|
|
104
|
+
print(f"[watcher] Job {base_stem.name} finished with rc={rc}")
|
|
105
|
+
exit_file.write_text(f"{rc}\n", encoding="utf-8")
|
|
106
|
+
print(f"[watcher] Job {base_stem.name} finished")
|
|
107
|
+
(done_file if rc == 0 else fail_file).touch()
|
|
108
|
+
finally:
|
|
109
|
+
# Cleanup markers depending on the claim type
|
|
110
|
+
if claimed_by_lock:
|
|
111
|
+
try:
|
|
112
|
+
if ready_file.exists():
|
|
113
|
+
ready_file.unlink()
|
|
114
|
+
except Exception as e:
|
|
115
|
+
print(
|
|
116
|
+
f"[watcher] Warning: failed to remove ready {ready_file}: {e}",
|
|
117
|
+
file=sys.stderr,
|
|
118
|
+
)
|
|
119
|
+
try:
|
|
120
|
+
if lock_file.exists():
|
|
121
|
+
lock_file.unlink()
|
|
122
|
+
except Exception as e:
|
|
123
|
+
print(
|
|
124
|
+
f"[watcher] Warning: failed to remove lock {lock_file}: {e}",
|
|
125
|
+
file=sys.stderr,
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
try:
|
|
129
|
+
if claim_file.exists():
|
|
130
|
+
claim_file.unlink()
|
|
131
|
+
except Exception as e:
|
|
132
|
+
print(
|
|
133
|
+
f"[watcher] Warning: failed to remove claim {claim_file}: {e}",
|
|
134
|
+
file=sys.stderr,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def try_claim(
|
|
139
|
+
ready_file: Path, retries: int = 5, backoff: float = 0.05
|
|
140
|
+
) -> Optional[Path]:
|
|
141
|
+
"""
|
|
142
|
+
Atomically rename *.ready -> *.claim. Retries on transient failures.
|
|
143
|
+
Returns claim path if successful, else None.
|
|
144
|
+
"""
|
|
145
|
+
claim = ready_file.with_suffix(".claim")
|
|
146
|
+
for i in range(max(1, retries)):
|
|
147
|
+
try:
|
|
148
|
+
os.replace(ready_file, claim)
|
|
149
|
+
return claim
|
|
150
|
+
except FileNotFoundError:
|
|
151
|
+
return None
|
|
152
|
+
except PermissionError:
|
|
153
|
+
# Windows/NFS transient locks: backoff and retry
|
|
154
|
+
time.sleep(backoff * (i + 1))
|
|
155
|
+
except OSError:
|
|
156
|
+
# Other transient FS issues: backoff and retry
|
|
157
|
+
time.sleep(backoff * (i + 1))
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def try_lock(base_stem: Path) -> bool:
|
|
162
|
+
"""
|
|
163
|
+
Create an exclusive lock file <id>.lock using O_CREAT|O_EXCL.
|
|
164
|
+
Returns True if lock acquired, False otherwise.
|
|
165
|
+
"""
|
|
166
|
+
lock_path = base_stem.with_suffix(".lock")
|
|
167
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
|
168
|
+
try:
|
|
169
|
+
fd = os.open(lock_path, flags, 0o644)
|
|
170
|
+
os.close(fd)
|
|
171
|
+
return True
|
|
172
|
+
except FileExistsError:
|
|
173
|
+
return False
|
|
174
|
+
except OSError as e:
|
|
175
|
+
print(f"[watcher] Lock create failed for {lock_path}: {e}", file=sys.stderr)
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main() -> int:
|
|
180
|
+
parser = argparse.ArgumentParser(
|
|
181
|
+
description="Concurrent file-queue watcher with robust claiming"
|
|
182
|
+
)
|
|
183
|
+
parser.add_argument(
|
|
184
|
+
"--queue-dir", type=Path, required=False, default=Path.cwd() / "queue"
|
|
185
|
+
)
|
|
186
|
+
parser.add_argument("--poll-interval", type=float, default=1.0)
|
|
187
|
+
parser.add_argument("--workers", type=int, default=min(4, (os.cpu_count() or 2)))
|
|
188
|
+
parser.add_argument(
|
|
189
|
+
"--use-srun",
|
|
190
|
+
action="store_true",
|
|
191
|
+
help="(deprecated; wrapper removed for simplicity)",
|
|
192
|
+
)
|
|
193
|
+
parser.add_argument("--srun-args", type=str, default="")
|
|
194
|
+
parser.add_argument(
|
|
195
|
+
"--once", action="store_true", help="Process current claims and exit"
|
|
196
|
+
)
|
|
197
|
+
args = parser.parse_args()
|
|
198
|
+
|
|
199
|
+
qd = args.queue_dir
|
|
200
|
+
qd.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
print(f"[watcher] Watching {qd}, workers={args.workers}")
|
|
202
|
+
|
|
203
|
+
stop = {"flag": False}
|
|
204
|
+
|
|
205
|
+
def handle_sig(_s, _f):
|
|
206
|
+
stop["flag"] = True
|
|
207
|
+
|
|
208
|
+
signal.signal(signal.SIGINT, handle_sig)
|
|
209
|
+
signal.signal(signal.SIGTERM, handle_sig)
|
|
210
|
+
|
|
211
|
+
with ThreadPoolExecutor(max_workers=max(1, args.workers)) as pool:
|
|
212
|
+
futures = set()
|
|
213
|
+
|
|
214
|
+
while not stop["flag"]:
|
|
215
|
+
claimed_any = False
|
|
216
|
+
|
|
217
|
+
# Scan for all .ready files
|
|
218
|
+
ready_list = list(qd.glob("*.ready"))
|
|
219
|
+
if ready_list:
|
|
220
|
+
print(f"[watcher] Found {len(ready_list)} ready file(s)")
|
|
221
|
+
for ready in ready_list:
|
|
222
|
+
base_stem = ready.with_suffix("") # drop .ready
|
|
223
|
+
# 1) Preferred: atomic rename to .claim (with retries)
|
|
224
|
+
claim = try_claim(ready)
|
|
225
|
+
if claim is not None:
|
|
226
|
+
claimed_any = True
|
|
227
|
+
futures.add(pool.submit(process_job_from_claim, base_stem, False))
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
# 2) Fallback: exclusive lock file if rename failed
|
|
231
|
+
if try_lock(base_stem):
|
|
232
|
+
print(f"[watcher] Claimed by lock: {base_stem.name}")
|
|
233
|
+
claimed_any = True
|
|
234
|
+
futures.add(pool.submit(process_job_from_claim, base_stem, True))
|
|
235
|
+
|
|
236
|
+
# Reap done tasks
|
|
237
|
+
done_now = {f for f in futures if f.done()}
|
|
238
|
+
for f in done_now:
|
|
239
|
+
try:
|
|
240
|
+
f.result()
|
|
241
|
+
except Exception as e:
|
|
242
|
+
print(f"[watcher] Job raised: {e}", file=sys.stderr)
|
|
243
|
+
futures.discard(f)
|
|
244
|
+
|
|
245
|
+
if args.once:
|
|
246
|
+
# Drain: wait for all running tasks to finish
|
|
247
|
+
for f in as_completed(list(futures)):
|
|
248
|
+
try:
|
|
249
|
+
f.result()
|
|
250
|
+
except Exception as e:
|
|
251
|
+
print(f"[watcher] Job raised: {e}", file=sys.stderr)
|
|
252
|
+
break
|
|
253
|
+
|
|
254
|
+
if not claimed_any:
|
|
255
|
+
time.sleep(max(0.05, args.poll_interval))
|
|
256
|
+
|
|
257
|
+
print("[watcher] Exiting")
|
|
258
|
+
return 0
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
sys.exit(main())
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# TODO rmeove duplicate route finder this is a test implementation
|
|
2
|
+
routes_table = [
|
|
3
|
+
{"target": "local", "source": "int-nano", "host": "local"},
|
|
4
|
+
{"target": "int-nano", "source": "local", "host": "local"},
|
|
5
|
+
{
|
|
6
|
+
"target": "horeka",
|
|
7
|
+
"source": "local",
|
|
8
|
+
"host": "horeka",
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"target": "local",
|
|
12
|
+
"source": "horeka",
|
|
13
|
+
"host": "horeka",
|
|
14
|
+
},
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def find_route(target, source):
|
|
19
|
+
"""
|
|
20
|
+
Find the minimal route from source to target using available routes.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
target (str): The target system
|
|
24
|
+
source (str): The source system
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
list: A list of systems to visit in order, or None if no route exists
|
|
28
|
+
"""
|
|
29
|
+
# If source and target are the same, no transfer needed
|
|
30
|
+
if source == target:
|
|
31
|
+
return [source]
|
|
32
|
+
|
|
33
|
+
# Build a graph of available routes
|
|
34
|
+
graph = {}
|
|
35
|
+
for route in routes_table:
|
|
36
|
+
if route["source"] not in graph:
|
|
37
|
+
graph[route["source"]] = []
|
|
38
|
+
graph[route["source"]].append(route["target"])
|
|
39
|
+
|
|
40
|
+
# If source isn't in our graph, we can't start a route
|
|
41
|
+
if source not in graph:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
# Use BFS to find the shortest path
|
|
45
|
+
visited = {source}
|
|
46
|
+
queue = [(source, [source])] # (current_node, path_so_far)
|
|
47
|
+
|
|
48
|
+
while queue:
|
|
49
|
+
current, path = queue.pop(0)
|
|
50
|
+
|
|
51
|
+
# Check neighbors (available transfer targets)
|
|
52
|
+
if current in graph:
|
|
53
|
+
for neighbor in graph[current]:
|
|
54
|
+
if neighbor == target:
|
|
55
|
+
# Found a path to target
|
|
56
|
+
return path + [neighbor]
|
|
57
|
+
|
|
58
|
+
if neighbor not in visited:
|
|
59
|
+
visited.add(neighbor)
|
|
60
|
+
queue.append((neighbor, path + [neighbor]))
|
|
61
|
+
|
|
62
|
+
# If we've exhausted all possibilities without finding a path
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
# Example usage
|
|
68
|
+
target = "int-nano"
|
|
69
|
+
source = "horeka"
|
|
70
|
+
route = find_route(target, source)
|
|
71
|
+
if route:
|
|
72
|
+
print(f"Route from {source} to {target}: {' -> '.join(route)}")
|
|
73
|
+
else:
|
|
74
|
+
print(f"No route found from {source} to {target}")
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import socket
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from simstack.core.context import context
|
|
10
|
+
from simstack.models.runner_model import RunnerEvent, RunnerType, RunnerEventEnum
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("RunnerUtil")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def ensure_crontab_entry(command, schedule="*/10 * * * *"):
|
|
16
|
+
"""
|
|
17
|
+
Checks if a crontab entry exists and adds it if it doesn't.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
command (str): The command to be executed by cron
|
|
21
|
+
schedule (str): The cron schedule expression (default: every 10 minutes)
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
bool: True if entry was added, False if it already existed
|
|
25
|
+
"""
|
|
26
|
+
# The full crontab entry to look for
|
|
27
|
+
cron_entry = f"{schedule} {command}"
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
# Get current crontab
|
|
31
|
+
result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
|
|
32
|
+
|
|
33
|
+
# Check if command succeeded
|
|
34
|
+
if result.returncode == 0:
|
|
35
|
+
current_crontab = result.stdout
|
|
36
|
+
else:
|
|
37
|
+
# If crontab is empty or doesn't exist
|
|
38
|
+
current_crontab = ""
|
|
39
|
+
|
|
40
|
+
# Check if our entry already exists (using regex to handle whitespace variations)
|
|
41
|
+
entry_pattern = (
|
|
42
|
+
re.escape(schedule.strip()) + r"\s+" + re.escape(command.strip())
|
|
43
|
+
)
|
|
44
|
+
if re.search(entry_pattern, current_crontab):
|
|
45
|
+
logger.info(f"Crontab entry for '{command}' already exists.")
|
|
46
|
+
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
# Add our entry to crontab
|
|
50
|
+
new_crontab = current_crontab
|
|
51
|
+
if new_crontab and not new_crontab.endswith("\n"):
|
|
52
|
+
new_crontab += "\n"
|
|
53
|
+
new_crontab += cron_entry + "\n"
|
|
54
|
+
|
|
55
|
+
# Write back to crontab
|
|
56
|
+
process = subprocess.Popen(["crontab", "-"], stdin=subprocess.PIPE, text=True)
|
|
57
|
+
process.communicate(input=new_crontab)
|
|
58
|
+
|
|
59
|
+
if process.returncode == 0:
|
|
60
|
+
logger.info(f"Successfully added crontab entry: '{cron_entry}'")
|
|
61
|
+
return True
|
|
62
|
+
else:
|
|
63
|
+
logger.error(f"Failed to update crontab, return code: {process.returncode}")
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.error(f"Error managing crontab: {str(e)}")
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def graceful_shutdown(resource_name):
|
|
72
|
+
# Log shutdown
|
|
73
|
+
|
|
74
|
+
pid = os.getpid()
|
|
75
|
+
user = os.environ.get("USER", os.environ.get("USERNAME", "unknown"))
|
|
76
|
+
hostname = socket.gethostname()
|
|
77
|
+
|
|
78
|
+
# Capture current user info for the subprocess
|
|
79
|
+
current_user = os.environ.get("USER", os.environ.get("USERNAME"))
|
|
80
|
+
|
|
81
|
+
runner_event = RunnerEvent(
|
|
82
|
+
resource=resource_name,
|
|
83
|
+
runner_type=RunnerType.RESOURCE_RUNNER,
|
|
84
|
+
event=RunnerEventEnum.SHUTDOWN,
|
|
85
|
+
pid=pid,
|
|
86
|
+
user=user,
|
|
87
|
+
hostname=hostname,
|
|
88
|
+
message=f"Graceful shutdown of {pid} by {current_user}",
|
|
89
|
+
)
|
|
90
|
+
await context.db.save(runner_event)
|
|
91
|
+
|
|
92
|
+
# Cancel all remaining tasks
|
|
93
|
+
for task in asyncio.all_tasks():
|
|
94
|
+
if task is not asyncio.current_task():
|
|
95
|
+
task.cancel()
|
|
96
|
+
|
|
97
|
+
# Give tasks time to cancel
|
|
98
|
+
await asyncio.sleep(0.5)
|
|
99
|
+
|
|
100
|
+
# Signal to the main function to exit
|
|
101
|
+
raise SystemExit(0)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def restart(resource_name):
|
|
105
|
+
logger.info(
|
|
106
|
+
f"Restarting node runner for resource: {resource_name} {context.config.python_path}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
cmd = Path(context.config.python_path[0]) / "scripts" / "check_runner.sh"
|
|
110
|
+
|
|
111
|
+
# Get current user information
|
|
112
|
+
current_uid = os.getuid()
|
|
113
|
+
current_gid = os.getgid()
|
|
114
|
+
current_pid = os.getpid()
|
|
115
|
+
logger.info(f"Current user ID: {current_uid}, group ID: {current_gid}")
|
|
116
|
+
|
|
117
|
+
# Create environment with preserved user information
|
|
118
|
+
env = os.environ.copy()
|
|
119
|
+
|
|
120
|
+
# Start a new process with the same arguments
|
|
121
|
+
|
|
122
|
+
logger.info(f"Restarting node runner for resource: {resource_name} {str(cmd)}")
|
|
123
|
+
|
|
124
|
+
result = subprocess.Popen(str(cmd), start_new_session=True, env=env)
|
|
125
|
+
# Log the restart
|
|
126
|
+
message = f"user: {current_uid} group: {current_gid} pid: {current_pid} result: {result.pid}"
|
|
127
|
+
runner_event = RunnerEvent(
|
|
128
|
+
resource=resource_name,
|
|
129
|
+
runner_type=RunnerType.RESOURCE_RUNNER,
|
|
130
|
+
event=RunnerEventEnum.RESTART,
|
|
131
|
+
message=message,
|
|
132
|
+
)
|
|
133
|
+
await context.db.save(runner_event)
|
|
134
|
+
# Exit current process
|
|
135
|
+
logger.info(f"Restarted node runner for resource: {resource_name}")
|
|
136
|
+
# await graceful_shutdown(resource_name)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def schedule_restart(resource_name, restart_minutes):
|
|
140
|
+
"""Schedule a restart after the specified minutes"""
|
|
141
|
+
try:
|
|
142
|
+
logger.info(
|
|
143
|
+
f"Runner will restart in {restart_minutes} minutes on resource {resource_name} "
|
|
144
|
+
)
|
|
145
|
+
await asyncio.sleep(restart_minutes * 60) # Convert minutes to seconds
|
|
146
|
+
# try:
|
|
147
|
+
# if context.config.python_path:
|
|
148
|
+
# path = Path(context.config.python_path[0])
|
|
149
|
+
# command = str(path / "scripts" / "check_runner.sh") + " >> " + str(path / "cron_log.log") + " 2>&1"
|
|
150
|
+
# logger.info(f"Path: {command} ")
|
|
151
|
+
# if not ensure_crontab_entry(command):
|
|
152
|
+
# runner_entry = RunnerEvent(resource=resource_name, runner_type=RunnerType.RESOURCE_RUNNER,
|
|
153
|
+
# event=RunnerEventEnum.CRONTAB_GONE,
|
|
154
|
+
# message="Crontab entry for resource runner was not found")
|
|
155
|
+
# await context.db.save(runner_entry)
|
|
156
|
+
# except Exception as e:
|
|
157
|
+
# logger.error("Something went wrong for crontab")
|
|
158
|
+
await restart(resource_name)
|
|
159
|
+
await graceful_shutdown(resource_name)
|
|
160
|
+
except asyncio.CancelledError:
|
|
161
|
+
logger.info("Restart task was cancelled")
|
|
162
|
+
raise
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from typing import Dict
|
|
3
|
+
|
|
4
|
+
from simstack.core.artifacts import ArtifactArguments
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def safe_code_executor(
|
|
8
|
+
code_string: str, artifact_arguments: ArtifactArguments, timeout: int = 30
|
|
9
|
+
) -> Dict:
|
|
10
|
+
"""
|
|
11
|
+
Safely executes Python code from a string with a controlled environment.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
code_string (str): The Python code to execute
|
|
15
|
+
artifact_arguments (ArtifactArguments): Artifact arguments to be used in the execution environment
|
|
16
|
+
timeout (int, optional): Maximum execution time in seconds before timeout
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Dict: A dictionary containing:
|
|
20
|
+
'success' (bool): Whether execution was successful
|
|
21
|
+
'result': The return value if successful
|
|
22
|
+
'error': Error message if unsuccessful
|
|
23
|
+
'error_type': Type of error if unsuccessful
|
|
24
|
+
|
|
25
|
+
"""
|
|
26
|
+
import ast
|
|
27
|
+
import traceback
|
|
28
|
+
|
|
29
|
+
# Default result structure
|
|
30
|
+
result = {"success": False, "result": None, "error": None, "error_type": None}
|
|
31
|
+
|
|
32
|
+
# Verify code is not empty
|
|
33
|
+
if not code_string or not code_string.strip():
|
|
34
|
+
result["error"] = "Empty code string provided"
|
|
35
|
+
result["error_type"] = "ValueError"
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
# Create a safe globals dictionary
|
|
39
|
+
safe_globals = {"__builtins__": {}}
|
|
40
|
+
|
|
41
|
+
# Add only whitelisted builtins that are considered safe
|
|
42
|
+
for name in [
|
|
43
|
+
"abs",
|
|
44
|
+
"all",
|
|
45
|
+
"any",
|
|
46
|
+
"bool",
|
|
47
|
+
"dict",
|
|
48
|
+
"enumerate",
|
|
49
|
+
"filter",
|
|
50
|
+
"float",
|
|
51
|
+
"frozenset",
|
|
52
|
+
"int",
|
|
53
|
+
"isinstance",
|
|
54
|
+
"issubclass",
|
|
55
|
+
"len",
|
|
56
|
+
"list",
|
|
57
|
+
"map",
|
|
58
|
+
"max",
|
|
59
|
+
"min",
|
|
60
|
+
"print",
|
|
61
|
+
"range",
|
|
62
|
+
"reversed",
|
|
63
|
+
"round",
|
|
64
|
+
"set",
|
|
65
|
+
"slice",
|
|
66
|
+
"sorted",
|
|
67
|
+
"str",
|
|
68
|
+
"sum",
|
|
69
|
+
"tuple",
|
|
70
|
+
"zip",
|
|
71
|
+
]:
|
|
72
|
+
safe_globals["__builtins__"][name] = __builtins__[name]
|
|
73
|
+
|
|
74
|
+
# Add artifact models to safe globals
|
|
75
|
+
from simstack.models.charts_artifact import (
|
|
76
|
+
ChartArtifactModel,
|
|
77
|
+
AGLineSeriesConfig,
|
|
78
|
+
AGChartAxisConfig,
|
|
79
|
+
AGChartTitleConfig,
|
|
80
|
+
AGChartLegendConfig,
|
|
81
|
+
)
|
|
82
|
+
from simstack.models.table_artifact import TableArtifactModel
|
|
83
|
+
from examples.science.electronic_structure.spectra.plot_spectra import (
|
|
84
|
+
make_multi_line_chart,
|
|
85
|
+
)
|
|
86
|
+
from simstack.models import ArtifactModel
|
|
87
|
+
|
|
88
|
+
safe_globals.update(
|
|
89
|
+
{
|
|
90
|
+
"ChartArtifactModel": ChartArtifactModel,
|
|
91
|
+
"TableArtifactModel": TableArtifactModel,
|
|
92
|
+
"AGLineSeriesConfig": AGLineSeriesConfig,
|
|
93
|
+
"AGChartAxisConfig": AGChartAxisConfig,
|
|
94
|
+
"AGChartTitleConfig": AGChartTitleConfig,
|
|
95
|
+
"AGChartLegendConfig": AGChartLegendConfig,
|
|
96
|
+
"make_multi_line_chart": make_multi_line_chart,
|
|
97
|
+
"ArtifactModel": ArtifactModel,
|
|
98
|
+
"ArtifactArguments": ArtifactArguments,
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Add artifact_arguments attributes to safe globals
|
|
103
|
+
# Extract all attributes from the artifact_arguments instance
|
|
104
|
+
for attr_name in dir(artifact_arguments):
|
|
105
|
+
if not attr_name.startswith("_"): # Skip private/magic methods
|
|
106
|
+
attr_value = getattr(artifact_arguments, attr_name)
|
|
107
|
+
if not callable(attr_value): # Skip methods, only include data attributes
|
|
108
|
+
safe_globals[attr_name] = attr_value
|
|
109
|
+
|
|
110
|
+
# Also add the full artifact_arguments object itself for backward compatibility
|
|
111
|
+
safe_globals["arg"] = artifact_arguments
|
|
112
|
+
|
|
113
|
+
# Verify code is syntactically correct
|
|
114
|
+
try:
|
|
115
|
+
ast.parse(code_string)
|
|
116
|
+
except SyntaxError as e:
|
|
117
|
+
result["error"] = str(e)
|
|
118
|
+
result["error_type"] = "SyntaxError"
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
# Cross-platform timeout implementation using threading
|
|
122
|
+
execution_result = {
|
|
123
|
+
"completed": False,
|
|
124
|
+
"exception": None,
|
|
125
|
+
"local_vars": {},
|
|
126
|
+
"function_result": None,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
def execute_code():
|
|
130
|
+
try:
|
|
131
|
+
local_vars = {}
|
|
132
|
+
exec(code_string, safe_globals, local_vars)
|
|
133
|
+
execution_result["local_vars"] = local_vars
|
|
134
|
+
|
|
135
|
+
# Look for a function in local_vars and call it with artifact_arguments
|
|
136
|
+
function_result = None
|
|
137
|
+
for var_name, var_value in local_vars.items():
|
|
138
|
+
if callable(var_value) and not var_name.startswith("_"):
|
|
139
|
+
# Found a function, call it with artifact_arguments
|
|
140
|
+
try:
|
|
141
|
+
function_result = var_value(artifact_arguments)
|
|
142
|
+
execution_result["function_result"] = function_result
|
|
143
|
+
break
|
|
144
|
+
except Exception as func_e:
|
|
145
|
+
execution_result["exception"] = func_e
|
|
146
|
+
execution_result["completed"] = True
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
execution_result["completed"] = True
|
|
150
|
+
except Exception as e:
|
|
151
|
+
execution_result["exception"] = e
|
|
152
|
+
execution_result["completed"] = True
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
# Start execution in a separate thread
|
|
156
|
+
thread = threading.Thread(target=execute_code, daemon=True)
|
|
157
|
+
thread.start()
|
|
158
|
+
thread.join(timeout=timeout)
|
|
159
|
+
|
|
160
|
+
if thread.is_alive():
|
|
161
|
+
# Timeout occurred
|
|
162
|
+
result["error"] = f"Code execution timed out after {timeout} seconds"
|
|
163
|
+
result["error_type"] = "TimeoutError"
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
if execution_result["exception"]:
|
|
167
|
+
# Exception occurred during execution
|
|
168
|
+
e = execution_result["exception"]
|
|
169
|
+
result["error"] = str(e)
|
|
170
|
+
result["error_type"] = e.__class__.__name__
|
|
171
|
+
result["traceback"] = traceback.format_exc()
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
# Successful execution
|
|
175
|
+
# Return the function result if available, otherwise return local vars
|
|
176
|
+
if execution_result["function_result"] is not None:
|
|
177
|
+
result["result"] = execution_result["function_result"]
|
|
178
|
+
else:
|
|
179
|
+
local_vars = execution_result["local_vars"]
|
|
180
|
+
if "result" in local_vars:
|
|
181
|
+
result["result"] = local_vars["result"]
|
|
182
|
+
else:
|
|
183
|
+
result["result"] = {
|
|
184
|
+
k: v for k, v in local_vars.items() if not k.startswith("_")
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
result["success"] = True
|
|
188
|
+
|
|
189
|
+
except Exception as e:
|
|
190
|
+
result["error"] = str(e)
|
|
191
|
+
result["error_type"] = e.__class__.__name__
|
|
192
|
+
result["traceback"] = traceback.format_exc()
|
|
193
|
+
|
|
194
|
+
return result
|