quartermaster-code-runner 0.0.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.
- quartermaster_code_runner/__init__.py +38 -0
- quartermaster_code_runner/app.py +269 -0
- quartermaster_code_runner/config.py +175 -0
- quartermaster_code_runner/errors.py +88 -0
- quartermaster_code_runner/execution.py +231 -0
- quartermaster_code_runner/images.py +397 -0
- quartermaster_code_runner/runtime/bun/Dockerfile +22 -0
- quartermaster_code_runner/runtime/bun/completions.json +34 -0
- quartermaster_code_runner/runtime/bun/entrypoint.sh +32 -0
- quartermaster_code_runner/runtime/bun/sdk.ts +87 -0
- quartermaster_code_runner/runtime/deno/Dockerfile +22 -0
- quartermaster_code_runner/runtime/deno/completions.json +34 -0
- quartermaster_code_runner/runtime/deno/entrypoint.sh +32 -0
- quartermaster_code_runner/runtime/deno/sdk.ts +88 -0
- quartermaster_code_runner/runtime/go/Dockerfile +18 -0
- quartermaster_code_runner/runtime/go/completions.json +22 -0
- quartermaster_code_runner/runtime/go/entrypoint.sh +50 -0
- quartermaster_code_runner/runtime/go/sdk.go +101 -0
- quartermaster_code_runner/runtime/node/Dockerfile +31 -0
- quartermaster_code_runner/runtime/node/completions.json +34 -0
- quartermaster_code_runner/runtime/node/entrypoint.sh +33 -0
- quartermaster_code_runner/runtime/node/mcp-client.js +274 -0
- quartermaster_code_runner/runtime/node/sdk.js +109 -0
- quartermaster_code_runner/runtime/python/Dockerfile +42 -0
- quartermaster_code_runner/runtime/python/completions.json +34 -0
- quartermaster_code_runner/runtime/python/entrypoint.sh +30 -0
- quartermaster_code_runner/runtime/python/mcp-client.py +276 -0
- quartermaster_code_runner/runtime/python/sdk.py +103 -0
- quartermaster_code_runner/runtime/rust/Cargo.toml.default +9 -0
- quartermaster_code_runner/runtime/rust/Dockerfile +27 -0
- quartermaster_code_runner/runtime/rust/completions.json +34 -0
- quartermaster_code_runner/runtime/rust/entrypoint.sh +38 -0
- quartermaster_code_runner/runtime/rust/sdk/Cargo.toml +9 -0
- quartermaster_code_runner/runtime/rust/sdk/src/lib.rs +149 -0
- quartermaster_code_runner/schemas.py +154 -0
- quartermaster_code_runner/security.py +81 -0
- quartermaster_code_runner-0.0.1.dist-info/METADATA +322 -0
- quartermaster_code_runner-0.0.1.dist-info/RECORD +40 -0
- quartermaster_code_runner-0.0.1.dist-info/WHEEL +4 -0
- quartermaster_code_runner-0.0.1.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
MCP Stdio Client - Executes MCP server commands and calls tools via stdio transport
|
|
4
|
+
|
|
5
|
+
Environment variables:
|
|
6
|
+
- MCP_COMMAND: The command to start the MCP server (e.g., "uvx mcp-server-fetch")
|
|
7
|
+
- MCP_OPERATION: The operation to perform: "list_tools" or "call_tool" (default: "call_tool")
|
|
8
|
+
- MCP_TOOL_NAME: The name of the tool to call (required for call_tool operation)
|
|
9
|
+
- MCP_TOOL_ARGUMENTS: JSON string of arguments to pass to the tool
|
|
10
|
+
- MCP_ENV_*: Additional environment variables to pass to the MCP server (prefix stripped)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
MCP_PROTOCOL_VERSION = "2024-11-05"
|
|
21
|
+
INITIALIZE_TIMEOUT = 60 # 60 seconds for slow package resolution
|
|
22
|
+
TOOL_CALL_TIMEOUT = 120
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class McpStdioClient:
|
|
26
|
+
def __init__(self, command: str, server_env: dict[str, str] | None = None):
|
|
27
|
+
self.command = command
|
|
28
|
+
self.server_env = server_env or {}
|
|
29
|
+
self.process: subprocess.Popen | None = None
|
|
30
|
+
self.message_id = 0
|
|
31
|
+
self.pending_requests: dict[int, dict] = {}
|
|
32
|
+
self.initialized = False
|
|
33
|
+
self._lock = threading.Lock()
|
|
34
|
+
self._response_event = threading.Event()
|
|
35
|
+
self._reader_thread: threading.Thread | None = None
|
|
36
|
+
self._stderr_thread: threading.Thread | None = None
|
|
37
|
+
self._exit_thread: threading.Thread | None = None
|
|
38
|
+
self._last_response: dict | None = None
|
|
39
|
+
self._server_stderr_lines: list[str] = []
|
|
40
|
+
self._process_exited = False
|
|
41
|
+
self._exit_code: int | None = None
|
|
42
|
+
|
|
43
|
+
def start(self):
|
|
44
|
+
env = os.environ.copy()
|
|
45
|
+
env.update(self.server_env)
|
|
46
|
+
|
|
47
|
+
for key in ["MCP_COMMAND", "MCP_TOOL_NAME", "MCP_TOOL_ARGUMENTS"]:
|
|
48
|
+
env.pop(key, None)
|
|
49
|
+
|
|
50
|
+
self.process = subprocess.Popen(
|
|
51
|
+
self.command,
|
|
52
|
+
shell=True,
|
|
53
|
+
stdin=subprocess.PIPE,
|
|
54
|
+
stdout=subprocess.PIPE,
|
|
55
|
+
stderr=subprocess.PIPE,
|
|
56
|
+
env=env,
|
|
57
|
+
text=True,
|
|
58
|
+
bufsize=1,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
self._reader_thread = threading.Thread(target=self._read_responses, daemon=True)
|
|
62
|
+
self._reader_thread.start()
|
|
63
|
+
self._stderr_thread = threading.Thread(target=self._read_stderr, daemon=True)
|
|
64
|
+
self._stderr_thread.start()
|
|
65
|
+
self._exit_thread = threading.Thread(target=self._monitor_exit, daemon=True)
|
|
66
|
+
self._exit_thread.start()
|
|
67
|
+
|
|
68
|
+
def _monitor_exit(self):
|
|
69
|
+
if not self.process:
|
|
70
|
+
return
|
|
71
|
+
self._exit_code = self.process.wait()
|
|
72
|
+
self._process_exited = True
|
|
73
|
+
self._response_event.set()
|
|
74
|
+
|
|
75
|
+
def _read_stderr(self):
|
|
76
|
+
if not self.process or not self.process.stderr:
|
|
77
|
+
return
|
|
78
|
+
for line in self.process.stderr:
|
|
79
|
+
line = line.rstrip("\n")
|
|
80
|
+
self._server_stderr_lines.append(line)
|
|
81
|
+
print(f"[MCP Server] {line}", file=sys.stderr)
|
|
82
|
+
|
|
83
|
+
def _read_responses(self):
|
|
84
|
+
if not self.process or not self.process.stdout:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
for line in self.process.stdout:
|
|
88
|
+
line = line.strip()
|
|
89
|
+
if not line:
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
message = json.loads(line)
|
|
93
|
+
self._handle_message(message)
|
|
94
|
+
except json.JSONDecodeError:
|
|
95
|
+
print(f"[MCP Server stdout] {line}", file=sys.stderr)
|
|
96
|
+
|
|
97
|
+
def _handle_message(self, message: dict):
|
|
98
|
+
msg_id = message.get("id")
|
|
99
|
+
if msg_id is not None and msg_id in self.pending_requests:
|
|
100
|
+
with self._lock:
|
|
101
|
+
self._last_response = message
|
|
102
|
+
self._response_event.set()
|
|
103
|
+
|
|
104
|
+
def _send_request(
|
|
105
|
+
self, method: str, params: dict | None = None, timeout: int = 30
|
|
106
|
+
) -> Any:
|
|
107
|
+
self.message_id += 1
|
|
108
|
+
request_id = self.message_id
|
|
109
|
+
|
|
110
|
+
request = {
|
|
111
|
+
"jsonrpc": "2.0",
|
|
112
|
+
"id": request_id,
|
|
113
|
+
"method": method,
|
|
114
|
+
"params": params or {},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
with self._lock:
|
|
118
|
+
self.pending_requests[request_id] = {}
|
|
119
|
+
self._response_event.clear()
|
|
120
|
+
self._last_response = None
|
|
121
|
+
|
|
122
|
+
if self._process_exited:
|
|
123
|
+
raise RuntimeError(
|
|
124
|
+
f"MCP server exited with code {self._exit_code} before {method} could be sent"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if self.process and self.process.stdin:
|
|
128
|
+
self.process.stdin.write(json.dumps(request) + "\n")
|
|
129
|
+
self.process.stdin.flush()
|
|
130
|
+
|
|
131
|
+
if not self._response_event.wait(timeout):
|
|
132
|
+
raise TimeoutError(f"{method} timed out after {timeout}s")
|
|
133
|
+
|
|
134
|
+
if self._process_exited and self._last_response is None:
|
|
135
|
+
stderr_tail = "\n".join(self._server_stderr_lines[-20:])
|
|
136
|
+
raise RuntimeError(
|
|
137
|
+
f"MCP server process exited with code {self._exit_code} during {method}.\n"
|
|
138
|
+
f"Server output:\n{stderr_tail}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
with self._lock:
|
|
142
|
+
response = self._last_response
|
|
143
|
+
self.pending_requests.pop(request_id, None)
|
|
144
|
+
|
|
145
|
+
if response and "error" in response:
|
|
146
|
+
error = response["error"]
|
|
147
|
+
raise Exception(error.get("message", str(error)))
|
|
148
|
+
|
|
149
|
+
return response.get("result") if response else None
|
|
150
|
+
|
|
151
|
+
def _send_notification(self, method: str, params: dict | None = None):
|
|
152
|
+
notification = {
|
|
153
|
+
"jsonrpc": "2.0",
|
|
154
|
+
"method": method,
|
|
155
|
+
"params": params or {},
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if self.process and self.process.stdin:
|
|
159
|
+
self.process.stdin.write(json.dumps(notification) + "\n")
|
|
160
|
+
self.process.stdin.flush()
|
|
161
|
+
|
|
162
|
+
def initialize(self) -> dict:
|
|
163
|
+
result = self._send_request(
|
|
164
|
+
"initialize",
|
|
165
|
+
{
|
|
166
|
+
"protocolVersion": MCP_PROTOCOL_VERSION,
|
|
167
|
+
"capabilities": {},
|
|
168
|
+
"clientInfo": {
|
|
169
|
+
"name": "quartermaster-code-runner",
|
|
170
|
+
"version": "1.0.0",
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
timeout=INITIALIZE_TIMEOUT,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
self._send_notification("notifications/initialized")
|
|
177
|
+
self.initialized = True
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
def list_tools(self) -> list[dict]:
|
|
181
|
+
result = self._send_request("tools/list", {})
|
|
182
|
+
return result.get("tools", []) if result else []
|
|
183
|
+
|
|
184
|
+
def call_tool(self, name: str, arguments: dict | None = None) -> str:
|
|
185
|
+
result = self._send_request(
|
|
186
|
+
"tools/call",
|
|
187
|
+
{"name": name, "arguments": arguments or {}},
|
|
188
|
+
timeout=TOOL_CALL_TIMEOUT,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
content = result.get("content", []) if result else []
|
|
192
|
+
text_parts = []
|
|
193
|
+
|
|
194
|
+
for item in content:
|
|
195
|
+
if item.get("type") == "text":
|
|
196
|
+
text_parts.append(item.get("text", ""))
|
|
197
|
+
elif item.get("type") == "image":
|
|
198
|
+
text_parts.append(f"[Image: {item.get('mimeType', 'unknown')}]")
|
|
199
|
+
elif item.get("type") == "resource":
|
|
200
|
+
text_parts.append(f"[Resource: {item.get('uri', 'unknown')}]")
|
|
201
|
+
|
|
202
|
+
return "\n".join(text_parts)
|
|
203
|
+
|
|
204
|
+
def close(self):
|
|
205
|
+
if self.process:
|
|
206
|
+
self.process.terminate()
|
|
207
|
+
try:
|
|
208
|
+
self.process.wait(timeout=5)
|
|
209
|
+
except subprocess.TimeoutExpired:
|
|
210
|
+
self.process.kill()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main():
|
|
214
|
+
command = os.environ.get("MCP_COMMAND")
|
|
215
|
+
operation = os.environ.get("MCP_OPERATION", "call_tool")
|
|
216
|
+
tool_name = os.environ.get("MCP_TOOL_NAME")
|
|
217
|
+
tool_arguments_json = os.environ.get("MCP_TOOL_ARGUMENTS", "{}")
|
|
218
|
+
|
|
219
|
+
if not command:
|
|
220
|
+
print(
|
|
221
|
+
"Error: MCP_COMMAND environment variable is required",
|
|
222
|
+
file=sys.stderr,
|
|
223
|
+
)
|
|
224
|
+
sys.exit(1)
|
|
225
|
+
|
|
226
|
+
if operation == "call_tool" and not tool_name:
|
|
227
|
+
print(
|
|
228
|
+
"Error: MCP_TOOL_NAME environment variable is required for call_tool operation",
|
|
229
|
+
file=sys.stderr,
|
|
230
|
+
)
|
|
231
|
+
sys.exit(1)
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
tool_arguments = json.loads(tool_arguments_json)
|
|
235
|
+
except json.JSONDecodeError:
|
|
236
|
+
print("Error: MCP_TOOL_ARGUMENTS must be valid JSON", file=sys.stderr)
|
|
237
|
+
sys.exit(1)
|
|
238
|
+
|
|
239
|
+
server_env = {}
|
|
240
|
+
for key, value in os.environ.items():
|
|
241
|
+
if key.startswith("MCP_ENV_"):
|
|
242
|
+
server_env[key[8:]] = value
|
|
243
|
+
|
|
244
|
+
client = McpStdioClient(command, server_env)
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
print(f"Starting MCP server: {command}", file=sys.stderr)
|
|
248
|
+
client.start()
|
|
249
|
+
|
|
250
|
+
print("Initializing MCP connection...", file=sys.stderr)
|
|
251
|
+
client.initialize()
|
|
252
|
+
|
|
253
|
+
if operation == "list_tools":
|
|
254
|
+
print("Listing tools...", file=sys.stderr)
|
|
255
|
+
tools = client.list_tools()
|
|
256
|
+
print(json.dumps(tools))
|
|
257
|
+
else:
|
|
258
|
+
print(f"Calling tool: {tool_name}", file=sys.stderr)
|
|
259
|
+
result = client.call_tool(tool_name or "", tool_arguments)
|
|
260
|
+
print(result)
|
|
261
|
+
|
|
262
|
+
client.close()
|
|
263
|
+
sys.exit(0)
|
|
264
|
+
except Exception as error:
|
|
265
|
+
print(f"MCP Error: {error}", file=sys.stderr)
|
|
266
|
+
if client._server_stderr_lines:
|
|
267
|
+
print("--- MCP Server stderr ---", file=sys.stderr)
|
|
268
|
+
for line in client._server_stderr_lines:
|
|
269
|
+
print(line, file=sys.stderr)
|
|
270
|
+
print("--- End server stderr ---", file=sys.stderr)
|
|
271
|
+
client.close()
|
|
272
|
+
sys.exit(1)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
if __name__ == "__main__":
|
|
276
|
+
main()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""quartermaster-code-runner SDK for Python runtime."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
_METADATA_FILE = "/metadata/.quartermaster_metadata.json"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def set_metadata(data):
|
|
12
|
+
"""
|
|
13
|
+
Set the result metadata to be returned to the backend.
|
|
14
|
+
|
|
15
|
+
This separates structured results from stdout/stderr logs.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
data: Any JSON-serializable data (dict, list, str, int, etc.)
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
from sdk import set_metadata
|
|
22
|
+
|
|
23
|
+
result = {"status": "success", "count": 42}
|
|
24
|
+
set_metadata(result)
|
|
25
|
+
"""
|
|
26
|
+
with open(_METADATA_FILE, "w") as f:
|
|
27
|
+
json.dump(data, f)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_metadata():
|
|
31
|
+
"""
|
|
32
|
+
Get previously set metadata (useful for reading/modifying).
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The previously set metadata, or None if not set.
|
|
36
|
+
"""
|
|
37
|
+
if not os.path.exists(_METADATA_FILE):
|
|
38
|
+
return None
|
|
39
|
+
with open(_METADATA_FILE, "r") as f:
|
|
40
|
+
return json.load(f)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_file(path):
|
|
44
|
+
"""Load a file from the flow's environment.
|
|
45
|
+
|
|
46
|
+
Only available during flow execution, not test runs.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
path: Path to the file within the environment.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The file content as a string.
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
from sdk import load_file
|
|
56
|
+
|
|
57
|
+
content = load_file("data/config.json")
|
|
58
|
+
"""
|
|
59
|
+
webdav_url = os.environ.get("QM_WEBDAV_URL")
|
|
60
|
+
if not webdav_url:
|
|
61
|
+
raise RuntimeError(
|
|
62
|
+
"load_file() is only available during flow execution. "
|
|
63
|
+
"For test runs, use mounted environments instead."
|
|
64
|
+
)
|
|
65
|
+
url = webdav_url.rstrip("/") + "/" + path.lstrip("/")
|
|
66
|
+
req = urllib.request.Request(url, method="GET")
|
|
67
|
+
try:
|
|
68
|
+
with urllib.request.urlopen(req) as resp:
|
|
69
|
+
return resp.read().decode("utf-8")
|
|
70
|
+
except urllib.error.HTTPError as e:
|
|
71
|
+
if e.code == 404:
|
|
72
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
73
|
+
raise RuntimeError(f"Failed to load file: {e}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def save_file(path, content):
|
|
77
|
+
"""Save a file to the flow's environment.
|
|
78
|
+
|
|
79
|
+
Only available during flow execution, not test runs.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
path: Path to the file within the environment.
|
|
83
|
+
content: The file content to save.
|
|
84
|
+
|
|
85
|
+
Example:
|
|
86
|
+
from sdk import save_file
|
|
87
|
+
|
|
88
|
+
save_file("output/result.txt", "Hello, world!")
|
|
89
|
+
"""
|
|
90
|
+
webdav_url = os.environ.get("QM_WEBDAV_URL")
|
|
91
|
+
if not webdav_url:
|
|
92
|
+
raise RuntimeError(
|
|
93
|
+
"save_file() is only available during flow execution. "
|
|
94
|
+
"For test runs, use mounted environments instead."
|
|
95
|
+
)
|
|
96
|
+
url = webdav_url.rstrip("/") + "/" + path.lstrip("/")
|
|
97
|
+
data = content.encode("utf-8")
|
|
98
|
+
req = urllib.request.Request(url, data=data, method="PUT")
|
|
99
|
+
req.add_header("Content-Type", "application/octet-stream")
|
|
100
|
+
try:
|
|
101
|
+
urllib.request.urlopen(req)
|
|
102
|
+
except urllib.error.HTTPError as e:
|
|
103
|
+
raise RuntimeError(f"Failed to save file: {e}")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
FROM rust:1.87-slim
|
|
2
|
+
|
|
3
|
+
LABEL qm.name="Rust"
|
|
4
|
+
LABEL qm.description="Rust 1.87 with Cargo"
|
|
5
|
+
LABEL qm.default_entrypoint="cargo run"
|
|
6
|
+
LABEL qm.file_extension=".rs"
|
|
7
|
+
LABEL qm.main_file="src/main.rs"
|
|
8
|
+
|
|
9
|
+
WORKDIR /app
|
|
10
|
+
|
|
11
|
+
RUN apt-get update && \
|
|
12
|
+
apt-get install -y tar && \
|
|
13
|
+
rm -rf /var/lib/apt/lists/* && \
|
|
14
|
+
useradd --uid 1001 --no-create-home --shell /bin/sh runner && \
|
|
15
|
+
mkdir -p /home/runner/.cargo && \
|
|
16
|
+
chown -R runner:runner /home/runner
|
|
17
|
+
|
|
18
|
+
# Pre-compile SDK with serde to cache dependencies
|
|
19
|
+
COPY sdk/ /app/sdk/
|
|
20
|
+
RUN cd /app/sdk && cargo build --release && \
|
|
21
|
+
chown -R runner:runner /app/sdk
|
|
22
|
+
|
|
23
|
+
COPY Cargo.toml.default .
|
|
24
|
+
COPY entrypoint.sh .
|
|
25
|
+
RUN chmod +x entrypoint.sh
|
|
26
|
+
|
|
27
|
+
ENTRYPOINT ["/app/entrypoint.sh"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"caption": "use sdk::set_metadata",
|
|
4
|
+
"value": "use sdk::set_metadata;",
|
|
5
|
+
"meta": "import",
|
|
6
|
+
"score": 1000
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"caption": "use sdk::get_metadata",
|
|
10
|
+
"value": "use sdk::get_metadata;",
|
|
11
|
+
"meta": "import",
|
|
12
|
+
"score": 1000
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"caption": "use sdk::{set_metadata, get_metadata}",
|
|
16
|
+
"value": "use sdk::{set_metadata, get_metadata};",
|
|
17
|
+
"meta": "import",
|
|
18
|
+
"score": 1000
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"caption": "set_metadata",
|
|
22
|
+
"snippet": "set_metadata(&$1).unwrap()",
|
|
23
|
+
"meta": "sdk",
|
|
24
|
+
"score": 1000,
|
|
25
|
+
"docText": "Set structured result metadata to be returned to the backend. Accepts any Serialize type."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"caption": "get_metadata",
|
|
29
|
+
"snippet": "get_metadata::<$1>().unwrap()",
|
|
30
|
+
"meta": "sdk",
|
|
31
|
+
"score": 1000,
|
|
32
|
+
"docText": "Get previously set metadata, deserializing into the specified type. Returns None if not set."
|
|
33
|
+
}
|
|
34
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
chown runner:runner /tmp
|
|
5
|
+
chown runner:runner /metadata 2>/dev/null || true
|
|
6
|
+
chown runner:runner /workspace 2>/dev/null || true
|
|
7
|
+
|
|
8
|
+
exec su --preserve-environment -s /bin/sh -c '
|
|
9
|
+
set -e
|
|
10
|
+
cd /workspace
|
|
11
|
+
export HOME=/workspace
|
|
12
|
+
export CARGO_HOME=/workspace/.cargo
|
|
13
|
+
mkdir -p $CARGO_HOME
|
|
14
|
+
|
|
15
|
+
# Decode code to default filename
|
|
16
|
+
if [ -n "$ENCODED_CODE" ]; then
|
|
17
|
+
echo "$ENCODED_CODE" | base64 -d > main.rs
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
# Extract additional files if provided
|
|
21
|
+
if [ -n "$ENCODED_FILES" ]; then
|
|
22
|
+
echo "$ENCODED_FILES" | base64 -d | tar -xz
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
# Copy default Cargo.toml if user did not provide one
|
|
26
|
+
if [ ! -f Cargo.toml ]; then
|
|
27
|
+
cp /app/Cargo.toml.default Cargo.toml
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
# Execute custom entrypoint or default
|
|
31
|
+
if [ -n "$CUSTOM_ENTRYPOINT" ]; then
|
|
32
|
+
exec sh -c "$CUSTOM_ENTRYPOINT"
|
|
33
|
+
else
|
|
34
|
+
mkdir -p src
|
|
35
|
+
mv main.rs src/main.rs
|
|
36
|
+
exec cargo run --release -q
|
|
37
|
+
fi
|
|
38
|
+
' runner
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
//! quartermaster-code-runner SDK for Rust runtime.
|
|
2
|
+
//!
|
|
3
|
+
//! # Example
|
|
4
|
+
//!
|
|
5
|
+
//! ```rust
|
|
6
|
+
//! use sdk::set_metadata;
|
|
7
|
+
//! use serde::Serialize;
|
|
8
|
+
//!
|
|
9
|
+
//! #[derive(Serialize)]
|
|
10
|
+
//! struct Result {
|
|
11
|
+
//! status: String,
|
|
12
|
+
//! count: i32,
|
|
13
|
+
//! }
|
|
14
|
+
//!
|
|
15
|
+
//! fn main() {
|
|
16
|
+
//! let result = Result { status: "success".into(), count: 42 };
|
|
17
|
+
//! set_metadata(&result).unwrap();
|
|
18
|
+
//! }
|
|
19
|
+
//! ```
|
|
20
|
+
|
|
21
|
+
use serde::de::DeserializeOwned;
|
|
22
|
+
use serde::Serialize;
|
|
23
|
+
use std::env;
|
|
24
|
+
use std::fs;
|
|
25
|
+
use std::io;
|
|
26
|
+
|
|
27
|
+
const METADATA_FILE: &str = "/metadata/.quartermaster_metadata.json";
|
|
28
|
+
|
|
29
|
+
/// Set the result metadata to be returned to the backend.
|
|
30
|
+
///
|
|
31
|
+
/// This separates structured results from stdout/stderr logs.
|
|
32
|
+
///
|
|
33
|
+
/// # Example
|
|
34
|
+
///
|
|
35
|
+
/// ```rust
|
|
36
|
+
/// use sdk::set_metadata;
|
|
37
|
+
/// use serde_json::json;
|
|
38
|
+
///
|
|
39
|
+
///
|
|
40
|
+
/// set_metadata(&json!({"status": "success", "count": 42})).unwrap();
|
|
41
|
+
/// ```
|
|
42
|
+
pub fn set_metadata<T: Serialize>(data: &T) -> io::Result<()> {
|
|
43
|
+
let json =
|
|
44
|
+
serde_json::to_string(data).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
|
45
|
+
fs::write(METADATA_FILE, json)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// Set metadata from a raw JSON string.
|
|
49
|
+
///
|
|
50
|
+
/// Use this if you already have JSON as a string.
|
|
51
|
+
///
|
|
52
|
+
/// # Example
|
|
53
|
+
///
|
|
54
|
+
/// ```rust
|
|
55
|
+
/// sdk::set_metadata_raw(r#"{"status": "success"}"#).unwrap();
|
|
56
|
+
/// ```
|
|
57
|
+
pub fn set_metadata_raw(json: &str) -> io::Result<()> {
|
|
58
|
+
fs::write(METADATA_FILE, json)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Get previously set metadata, deserializing into the specified type.
|
|
62
|
+
///
|
|
63
|
+
/// Returns `None` if no metadata has been set.
|
|
64
|
+
pub fn get_metadata<T: DeserializeOwned>() -> io::Result<Option<T>> {
|
|
65
|
+
match fs::read_to_string(METADATA_FILE) {
|
|
66
|
+
Ok(content) => {
|
|
67
|
+
let data = serde_json::from_str(&content)
|
|
68
|
+
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
|
69
|
+
Ok(Some(data))
|
|
70
|
+
}
|
|
71
|
+
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
|
|
72
|
+
Err(e) => Err(e),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// Get previously set metadata as a raw JSON string.
|
|
77
|
+
///
|
|
78
|
+
/// Returns `None` if no metadata has been set.
|
|
79
|
+
pub fn get_metadata_raw() -> io::Result<Option<String>> {
|
|
80
|
+
match fs::read_to_string(METADATA_FILE) {
|
|
81
|
+
Ok(content) => Ok(Some(content)),
|
|
82
|
+
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
|
|
83
|
+
Err(e) => Err(e),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
fn webdav_url(path: &str) -> Result<String, io::Error> {
|
|
88
|
+
let base = env::var("QM_WEBDAV_URL").map_err(|_| {
|
|
89
|
+
io::Error::new(
|
|
90
|
+
io::ErrorKind::NotFound,
|
|
91
|
+
"load_file/save_file is only available during flow execution. \
|
|
92
|
+
For test runs, use mounted environments instead.",
|
|
93
|
+
)
|
|
94
|
+
})?;
|
|
95
|
+
Ok(format!(
|
|
96
|
+
"{}/{}",
|
|
97
|
+
base.trim_end_matches('/'),
|
|
98
|
+
path.trim_start_matches('/')
|
|
99
|
+
))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// Load a file from the flow's environment.
|
|
103
|
+
///
|
|
104
|
+
/// Only available during flow execution, not test runs.
|
|
105
|
+
///
|
|
106
|
+
/// # Example
|
|
107
|
+
///
|
|
108
|
+
/// ```rust,no_run
|
|
109
|
+
/// let content = sdk::load_file("data/config.json").unwrap();
|
|
110
|
+
/// println!("{}", content);
|
|
111
|
+
/// ```
|
|
112
|
+
pub fn load_file(path: &str) -> io::Result<String> {
|
|
113
|
+
let url = webdav_url(path)?;
|
|
114
|
+
let resp = ureq::get(&url).call().map_err(|e| match &e {
|
|
115
|
+
ureq::Error::Status(404, _) => io::Error::new(
|
|
116
|
+
io::ErrorKind::NotFound,
|
|
117
|
+
format!("File not found: {}", path),
|
|
118
|
+
),
|
|
119
|
+
_ => io::Error::new(
|
|
120
|
+
io::ErrorKind::Other,
|
|
121
|
+
format!("Failed to load file: {}", e),
|
|
122
|
+
),
|
|
123
|
+
})?;
|
|
124
|
+
resp.into_string()
|
|
125
|
+
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// Save a file to the flow's environment.
|
|
129
|
+
///
|
|
130
|
+
/// Only available during flow execution, not test runs.
|
|
131
|
+
///
|
|
132
|
+
/// # Example
|
|
133
|
+
///
|
|
134
|
+
/// ```rust,no_run
|
|
135
|
+
/// sdk::save_file("output/result.txt", "Hello, world!").unwrap();
|
|
136
|
+
/// ```
|
|
137
|
+
pub fn save_file(path: &str, content: &str) -> io::Result<()> {
|
|
138
|
+
let url = webdav_url(path)?;
|
|
139
|
+
ureq::put(&url)
|
|
140
|
+
.set("Content-Type", "application/octet-stream")
|
|
141
|
+
.send_string(content)
|
|
142
|
+
.map_err(|e| {
|
|
143
|
+
io::Error::new(
|
|
144
|
+
io::ErrorKind::Other,
|
|
145
|
+
format!("Failed to save file: {}", e),
|
|
146
|
+
)
|
|
147
|
+
})?;
|
|
148
|
+
Ok(())
|
|
149
|
+
}
|