runbuoy 0.1.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.
- runbuoy/__init__.py +6 -0
- runbuoy/__main__.py +4 -0
- runbuoy/cli/__init__.py +1 -0
- runbuoy/cli/app.py +480 -0
- runbuoy/config.py +115 -0
- runbuoy/executors/__init__.py +3 -0
- runbuoy/executors/tmux.py +68 -0
- runbuoy/ids.py +19 -0
- runbuoy/models.py +141 -0
- runbuoy/networking/__init__.py +3 -0
- runbuoy/networking/client.py +123 -0
- runbuoy/pairing/__init__.py +3 -0
- runbuoy/pairing/flow.py +82 -0
- runbuoy/paths.py +68 -0
- runbuoy/persistence/__init__.py +3 -0
- runbuoy/persistence/store.py +335 -0
- runbuoy/progress/__init__.py +17 -0
- runbuoy/progress/adapters.py +208 -0
- runbuoy/sdk.py +57 -0
- runbuoy/security/__init__.py +1 -0
- runbuoy/security/redaction.py +70 -0
- runbuoy/security/titles.py +35 -0
- runbuoy/worker/__init__.py +1 -0
- runbuoy/worker/runtime.py +323 -0
- runbuoy/worker/signals.py +32 -0
- runbuoy/worker/socket_server.py +102 -0
- runbuoy-0.1.0.dist-info/METADATA +114 -0
- runbuoy-0.1.0.dist-info/RECORD +31 -0
- runbuoy-0.1.0.dist-info/WHEEL +4 -0
- runbuoy-0.1.0.dist-info/entry_points.txt +2 -0
- runbuoy-0.1.0.dist-info/licenses/LICENSE +21 -0
runbuoy/__init__.py
ADDED
runbuoy/__main__.py
ADDED
runbuoy/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Typer command surface."""
|
runbuoy/cli/app.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from pydantic import ValidationError
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from runbuoy import __version__, sdk
|
|
19
|
+
from runbuoy.config import (
|
|
20
|
+
Config,
|
|
21
|
+
CredentialStore,
|
|
22
|
+
ensure_machine_identity,
|
|
23
|
+
ephemeral_token,
|
|
24
|
+
load_config,
|
|
25
|
+
save_config,
|
|
26
|
+
)
|
|
27
|
+
from runbuoy.executors.tmux import TmuxExecutor
|
|
28
|
+
from runbuoy.ids import uuid7
|
|
29
|
+
from runbuoy.models import ProgressMode, RunManifest
|
|
30
|
+
from runbuoy.networking.client import RemoteClient, flush_pending
|
|
31
|
+
from runbuoy.pairing.flow import pair_machine, public_pairing_fields
|
|
32
|
+
from runbuoy.paths import AppPaths
|
|
33
|
+
from runbuoy.persistence.store import EventQueue
|
|
34
|
+
from runbuoy.security.redaction import safe_message
|
|
35
|
+
from runbuoy.security.titles import safe_title
|
|
36
|
+
from runbuoy.worker.runtime import run_worker
|
|
37
|
+
|
|
38
|
+
app = typer.Typer(
|
|
39
|
+
name="runbuoy",
|
|
40
|
+
help="Keep every run in sight without exposing remote control.",
|
|
41
|
+
no_args_is_help=True,
|
|
42
|
+
)
|
|
43
|
+
emit_app = typer.Typer(help="Emit a structured event to the current local Run.")
|
|
44
|
+
app.add_typer(emit_app, name="emit")
|
|
45
|
+
console = Console(stderr=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _context() -> tuple[AppPaths, Config, CredentialStore, EventQueue]:
|
|
49
|
+
paths = AppPaths.discover()
|
|
50
|
+
paths.ensure()
|
|
51
|
+
config = load_config(paths)
|
|
52
|
+
return paths, config, CredentialStore(paths), EventQueue(paths.database)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _json_print(value: Any) -> None:
|
|
56
|
+
typer.echo(json.dumps(value, sort_keys=True, default=str))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _flush_if_paired(config: Config, credentials: CredentialStore, queue: EventQueue) -> int:
|
|
60
|
+
if credentials.get("machine_credential") is None:
|
|
61
|
+
return 0
|
|
62
|
+
client = RemoteClient(config, credentials)
|
|
63
|
+
try:
|
|
64
|
+
return flush_pending(queue, client, batch_size=config.batch_size)
|
|
65
|
+
finally:
|
|
66
|
+
client.close()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _send_local(path: str, token: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
70
|
+
request = {"token": token, **payload}
|
|
71
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
|
72
|
+
client.settimeout(3)
|
|
73
|
+
client.connect(path)
|
|
74
|
+
client.sendall(json.dumps(request).encode() + b"\n")
|
|
75
|
+
response = bytearray()
|
|
76
|
+
while b"\n" not in response:
|
|
77
|
+
chunk = client.recv(4096)
|
|
78
|
+
if not chunk:
|
|
79
|
+
break
|
|
80
|
+
response.extend(chunk)
|
|
81
|
+
return dict(json.loads(bytes(response).split(b"\n", 1)[0]))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command(
|
|
85
|
+
"run",
|
|
86
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
87
|
+
)
|
|
88
|
+
def run_command(
|
|
89
|
+
command: list[str] = typer.Argument(..., help="Command and arguments after --"),
|
|
90
|
+
title: str | None = typer.Option(None, "--title"),
|
|
91
|
+
progress_mode: ProgressMode = typer.Option(ProgressMode.INDETERMINATE, "--progress"),
|
|
92
|
+
pattern: str | None = typer.Option(None, "--pattern"),
|
|
93
|
+
total: float | None = typer.Option(None, "--total"),
|
|
94
|
+
match: str | None = typer.Option(None, "--match"),
|
|
95
|
+
unit: str | None = typer.Option(None, "--unit"),
|
|
96
|
+
share_log_tail: int = typer.Option(0, "--share-log-tail", min=0, max=100),
|
|
97
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
98
|
+
non_interactive: bool = typer.Option(False, "--non-interactive"),
|
|
99
|
+
wait: bool = typer.Option(False, "--wait", help="Wait locally for the result"),
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Start a persistent local tmux worker; target argv never enters remote payloads."""
|
|
102
|
+
del non_interactive
|
|
103
|
+
paths, config, credentials, queue = _context()
|
|
104
|
+
if not command:
|
|
105
|
+
raise typer.BadParameter("a command is required after --")
|
|
106
|
+
if progress_mode == ProgressMode.LINES and (total is None or total <= 0):
|
|
107
|
+
raise typer.BadParameter("--total > 0 is required for lines progress")
|
|
108
|
+
if progress_mode == ProgressMode.REGEX and not pattern:
|
|
109
|
+
raise typer.BadParameter("--pattern is required for regex progress")
|
|
110
|
+
if total is not None and total <= 0:
|
|
111
|
+
raise typer.BadParameter("--total must be greater than zero")
|
|
112
|
+
config = ensure_machine_identity(paths, config)
|
|
113
|
+
machine_id = config.machine_id
|
|
114
|
+
if machine_id is None: # pragma: no cover - guaranteed by ensure_machine_identity
|
|
115
|
+
raise RuntimeError("machine identity initialization failed")
|
|
116
|
+
run_id = str(uuid7())
|
|
117
|
+
run_dir = paths.run_dir(run_id)
|
|
118
|
+
manifest_path = run_dir / "manifest.json"
|
|
119
|
+
log_path = run_dir / "run.log"
|
|
120
|
+
result_path = run_dir / "result.json"
|
|
121
|
+
socket_path = paths.event_socket(run_id)
|
|
122
|
+
session = f"runbuoy-{run_id.replace('-', '')[:16]}"
|
|
123
|
+
manifest = RunManifest(
|
|
124
|
+
run_id=run_id,
|
|
125
|
+
machine_id=machine_id,
|
|
126
|
+
title=safe_title(command, title),
|
|
127
|
+
argv=command,
|
|
128
|
+
cwd=os.getcwd(),
|
|
129
|
+
progress_mode=progress_mode,
|
|
130
|
+
pattern=pattern,
|
|
131
|
+
total=total,
|
|
132
|
+
match=match,
|
|
133
|
+
unit=unit,
|
|
134
|
+
share_log_tail=share_log_tail,
|
|
135
|
+
socket_path=str(socket_path),
|
|
136
|
+
socket_token=ephemeral_token(),
|
|
137
|
+
log_path=str(log_path),
|
|
138
|
+
result_path=str(result_path),
|
|
139
|
+
cancel_grace_seconds=config.cancel_grace_seconds,
|
|
140
|
+
)
|
|
141
|
+
manifest.write_securely(manifest_path)
|
|
142
|
+
queue.create_run(
|
|
143
|
+
run_id=run_id,
|
|
144
|
+
machine_id=machine_id,
|
|
145
|
+
title=manifest.title,
|
|
146
|
+
manifest_path=str(manifest_path),
|
|
147
|
+
log_path=str(log_path),
|
|
148
|
+
result_path=str(result_path),
|
|
149
|
+
socket_path=str(socket_path),
|
|
150
|
+
tmux_session=session,
|
|
151
|
+
)
|
|
152
|
+
try:
|
|
153
|
+
TmuxExecutor().start(session, manifest_path)
|
|
154
|
+
except Exception as error:
|
|
155
|
+
queue.append_event(
|
|
156
|
+
run_id,
|
|
157
|
+
"run.lost",
|
|
158
|
+
{"termination_reason": "worker_start_failed", "message": safe_message(str(error))},
|
|
159
|
+
)
|
|
160
|
+
raise typer.BadParameter(str(error)) from error
|
|
161
|
+
response = {
|
|
162
|
+
"ok": True,
|
|
163
|
+
"run_id": run_id,
|
|
164
|
+
"title": manifest.title,
|
|
165
|
+
"status": "STARTING",
|
|
166
|
+
"local": {
|
|
167
|
+
"status": f"runbuoy status {run_id}",
|
|
168
|
+
"logs": f"runbuoy logs {run_id}",
|
|
169
|
+
"attach": f"runbuoy attach {run_id}",
|
|
170
|
+
"cancel": f"runbuoy cancel {run_id}",
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
if json_output:
|
|
174
|
+
_json_print(response)
|
|
175
|
+
else:
|
|
176
|
+
typer.echo(f"Run {run_id} started: {manifest.title}")
|
|
177
|
+
typer.echo(f"Status: runbuoy status {run_id}")
|
|
178
|
+
if wait:
|
|
179
|
+
while not result_path.exists():
|
|
180
|
+
time.sleep(0.1)
|
|
181
|
+
result = json.loads(result_path.read_text(encoding="utf-8"))
|
|
182
|
+
if json_output:
|
|
183
|
+
_json_print({"ok": result["exit_code"] == 0, "result": result})
|
|
184
|
+
raise typer.Exit(code=int(result["exit_code"]))
|
|
185
|
+
_flush_if_paired(config, credentials, queue)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@app.command("list")
|
|
189
|
+
def list_runs(
|
|
190
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
191
|
+
limit: int = typer.Option(20, "--limit", min=1, max=200),
|
|
192
|
+
) -> None:
|
|
193
|
+
paths, config, credentials, queue = _context()
|
|
194
|
+
del paths
|
|
195
|
+
_flush_if_paired(config, credentials, queue)
|
|
196
|
+
runs = queue.list_runs(limit)
|
|
197
|
+
if json_output:
|
|
198
|
+
_json_print({"runs": runs})
|
|
199
|
+
return
|
|
200
|
+
table = Table("Run ID", "Title", "Status", "Updated")
|
|
201
|
+
for item in runs:
|
|
202
|
+
table.add_row(item["run_id"], item["title"], item["status"], item["updated_at"])
|
|
203
|
+
console.print(table)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@app.command()
|
|
207
|
+
def status(
|
|
208
|
+
run_id: str,
|
|
209
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
210
|
+
) -> None:
|
|
211
|
+
_paths, config, credentials, queue = _context()
|
|
212
|
+
_flush_if_paired(config, credentials, queue)
|
|
213
|
+
item = queue.get_run(run_id)
|
|
214
|
+
if item is None:
|
|
215
|
+
raise typer.BadParameter(f"unknown run: {run_id}")
|
|
216
|
+
if json_output:
|
|
217
|
+
_json_print({"run": item})
|
|
218
|
+
else:
|
|
219
|
+
for key in ("run_id", "title", "status", "progress", "phase", "safe_message", "exit_code"):
|
|
220
|
+
typer.echo(f"{key}: {item.get(key)}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.command()
|
|
224
|
+
def logs(
|
|
225
|
+
run_id: str,
|
|
226
|
+
follow: bool = typer.Option(False, "--follow", "-f"),
|
|
227
|
+
lines: int = typer.Option(200, "--lines", min=1, max=10_000),
|
|
228
|
+
) -> None:
|
|
229
|
+
_paths, _config, _credentials, queue = _context()
|
|
230
|
+
item = queue.get_run(run_id)
|
|
231
|
+
if item is None:
|
|
232
|
+
raise typer.BadParameter(f"unknown run: {run_id}")
|
|
233
|
+
path = Path(item["log_path"])
|
|
234
|
+
if not path.exists():
|
|
235
|
+
raise typer.BadParameter("log has not been created yet")
|
|
236
|
+
existing = path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]
|
|
237
|
+
typer.echo("\n".join(existing))
|
|
238
|
+
if follow:
|
|
239
|
+
subprocess.call(["tail", "-n", "0", "-f", str(path)])
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@app.command()
|
|
243
|
+
def attach(run_id: str) -> None:
|
|
244
|
+
_paths, _config, _credentials, queue = _context()
|
|
245
|
+
item = queue.get_run(run_id)
|
|
246
|
+
if item is None:
|
|
247
|
+
raise typer.BadParameter(f"unknown run: {run_id}")
|
|
248
|
+
session = item.get("tmux_session")
|
|
249
|
+
if not session or not TmuxExecutor().exists(session):
|
|
250
|
+
raise typer.BadParameter("local tmux session is no longer active")
|
|
251
|
+
raise typer.Exit(TmuxExecutor().attach(session))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@app.command()
|
|
255
|
+
def cancel(
|
|
256
|
+
run_id: str,
|
|
257
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
258
|
+
) -> None:
|
|
259
|
+
_paths, _config, _credentials, queue = _context()
|
|
260
|
+
item = queue.get_run(run_id)
|
|
261
|
+
if item is None:
|
|
262
|
+
raise typer.BadParameter(f"unknown run: {run_id}")
|
|
263
|
+
if item["status"] in {"SUCCEEDED", "FAILED", "CANCELLED", "LOST"}:
|
|
264
|
+
raise typer.BadParameter(f"run is already {item['status']}")
|
|
265
|
+
manifest = RunManifest.model_validate_json(
|
|
266
|
+
Path(item["manifest_path"]).read_text(encoding="utf-8")
|
|
267
|
+
)
|
|
268
|
+
try:
|
|
269
|
+
response = _send_local(manifest.socket_path, manifest.socket_token, {"kind": "cancel"})
|
|
270
|
+
except OSError as error:
|
|
271
|
+
raise typer.BadParameter("local worker is not reachable") from error
|
|
272
|
+
if not response.get("ok"):
|
|
273
|
+
raise typer.BadParameter(f"cancel rejected: {response.get('error')}")
|
|
274
|
+
if json_output:
|
|
275
|
+
_json_print({"ok": True, "run_id": run_id, "requested": "local_cancel"})
|
|
276
|
+
else:
|
|
277
|
+
typer.echo(f"Local cancellation requested for {run_id}")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@app.command()
|
|
281
|
+
def notify(
|
|
282
|
+
title: str = typer.Option(..., "--title"),
|
|
283
|
+
body: str = typer.Option(..., "--body"),
|
|
284
|
+
subtitle: str | None = typer.Option(None, "--subtitle"),
|
|
285
|
+
level: str = typer.Option("info", "--level"),
|
|
286
|
+
field: list[str] | None = typer.Option(None, "--field", help="Repeat label=value"),
|
|
287
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
288
|
+
) -> None:
|
|
289
|
+
_paths, config, credentials, _queue = _context()
|
|
290
|
+
if level not in {"info", "success", "warning", "error"}:
|
|
291
|
+
raise typer.BadParameter("level must be info, success, warning, or error")
|
|
292
|
+
fields: list[dict[str, str]] = []
|
|
293
|
+
for raw in field or []:
|
|
294
|
+
if "=" not in raw:
|
|
295
|
+
raise typer.BadParameter("--field must be label=value")
|
|
296
|
+
label, value = raw.split("=", 1)
|
|
297
|
+
fields.append(
|
|
298
|
+
{"label": safe_message(label, 80) or "", "value": safe_message(value, 300) or ""}
|
|
299
|
+
)
|
|
300
|
+
payload = {
|
|
301
|
+
"title": safe_title(["notify"], title),
|
|
302
|
+
"subtitle": safe_message(subtitle, 120),
|
|
303
|
+
"body": safe_message(body, 2_000),
|
|
304
|
+
"level": level,
|
|
305
|
+
"fields": fields,
|
|
306
|
+
"source": "cli",
|
|
307
|
+
"machine_id": config.machine_id,
|
|
308
|
+
}
|
|
309
|
+
if credentials.get("machine_credential") is None:
|
|
310
|
+
raise typer.BadParameter("not paired; run `runbuoy pair` first")
|
|
311
|
+
client = RemoteClient(config, credentials)
|
|
312
|
+
try:
|
|
313
|
+
result = client.notify(payload)
|
|
314
|
+
finally:
|
|
315
|
+
client.close()
|
|
316
|
+
if json_output:
|
|
317
|
+
_json_print({"ok": True, "notification": result})
|
|
318
|
+
else:
|
|
319
|
+
typer.echo("Notification accepted")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@app.command()
|
|
323
|
+
def pair(
|
|
324
|
+
no_wait: bool = typer.Option(False, "--no-wait"),
|
|
325
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
326
|
+
) -> None:
|
|
327
|
+
paths, config, credentials, _queue = _context()
|
|
328
|
+
config = ensure_machine_identity(paths, config)
|
|
329
|
+
|
|
330
|
+
def show_created(created: dict[str, Any], qr_value: str) -> None:
|
|
331
|
+
if json_output:
|
|
332
|
+
_json_print({"ok": True, "state": "pending", "pairing": created, "qr": qr_value})
|
|
333
|
+
return
|
|
334
|
+
import qrcode
|
|
335
|
+
|
|
336
|
+
qr = qrcode.QRCode(border=1)
|
|
337
|
+
qr.add_data(qr_value)
|
|
338
|
+
qr.print_ascii(invert=True)
|
|
339
|
+
code = created.get("short_code") or created.get("challenge")
|
|
340
|
+
typer.echo(f"Code: {code}")
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
result, _qr_value = pair_machine(
|
|
344
|
+
config,
|
|
345
|
+
credentials,
|
|
346
|
+
wait=not no_wait,
|
|
347
|
+
on_created=show_created,
|
|
348
|
+
)
|
|
349
|
+
except Exception as error:
|
|
350
|
+
raise typer.BadParameter(str(error)) from error
|
|
351
|
+
machine_id = result.get("machine_id")
|
|
352
|
+
if machine_id:
|
|
353
|
+
config = config.model_copy(update={"machine_id": str(machine_id)})
|
|
354
|
+
save_config(paths, config)
|
|
355
|
+
safe_result = public_pairing_fields(result)
|
|
356
|
+
if json_output and not no_wait:
|
|
357
|
+
_json_print({"ok": True, "state": "paired", "pairing": safe_result})
|
|
358
|
+
elif result.get("status") == "paired":
|
|
359
|
+
typer.echo("Machine paired")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@app.command()
|
|
363
|
+
def doctor(json_output: bool = typer.Option(False, "--json")) -> None:
|
|
364
|
+
_paths, config, credentials, queue = _context()
|
|
365
|
+
checks: dict[str, Any] = {
|
|
366
|
+
"cli_installed": True,
|
|
367
|
+
"cli_version": __version__,
|
|
368
|
+
"python_supported": sys.version_info >= (3, 12),
|
|
369
|
+
"platform_supported": platform.system() in {"Darwin", "Linux"},
|
|
370
|
+
"tmux_available": TmuxExecutor.available(),
|
|
371
|
+
"paired": credentials.get("machine_credential") is not None,
|
|
372
|
+
"server_reachable": False,
|
|
373
|
+
"pending_events": len(queue.pending_events(100)),
|
|
374
|
+
}
|
|
375
|
+
try:
|
|
376
|
+
import httpx
|
|
377
|
+
|
|
378
|
+
response = httpx.get(str(config.server_url).rstrip("/") + "/healthz", timeout=2)
|
|
379
|
+
checks["server_reachable"] = response.status_code < 500
|
|
380
|
+
except Exception:
|
|
381
|
+
pass
|
|
382
|
+
required = ("python_supported", "platform_supported", "tmux_available")
|
|
383
|
+
result = {"ok": all(checks[key] for key in required), "checks": checks}
|
|
384
|
+
if json_output:
|
|
385
|
+
_json_print(result)
|
|
386
|
+
else:
|
|
387
|
+
for key, value in checks.items():
|
|
388
|
+
typer.echo(f"{key}: {value}")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@app.command()
|
|
392
|
+
def capabilities(json_output: bool = typer.Option(False, "--json")) -> None:
|
|
393
|
+
progress_modes = [mode.value for mode in ProgressMode]
|
|
394
|
+
result = {
|
|
395
|
+
"schema_version": 1,
|
|
396
|
+
"platforms": ["macos", "linux"],
|
|
397
|
+
"progress_modes": progress_modes,
|
|
398
|
+
"local_commands": ["list", "status", "logs", "attach", "cancel"],
|
|
399
|
+
"remote_control": False,
|
|
400
|
+
"inbound_tcp": False,
|
|
401
|
+
"full_logs_uploaded_by_default": False,
|
|
402
|
+
}
|
|
403
|
+
if json_output:
|
|
404
|
+
_json_print(result)
|
|
405
|
+
else:
|
|
406
|
+
typer.echo("Progress: " + ", ".join(progress_modes))
|
|
407
|
+
typer.echo("Remote control: disabled")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@app.command("config")
|
|
411
|
+
def config_command(
|
|
412
|
+
server_url: str | None = typer.Option(None, "--server-url"),
|
|
413
|
+
machine_name: str | None = typer.Option(None, "--machine-name"),
|
|
414
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
415
|
+
) -> None:
|
|
416
|
+
paths, config, _credentials, _queue = _context()
|
|
417
|
+
updates: dict[str, Any] = {}
|
|
418
|
+
if server_url:
|
|
419
|
+
updates["server_url"] = server_url
|
|
420
|
+
if machine_name:
|
|
421
|
+
updates["machine_name"] = safe_message(machine_name, 120)
|
|
422
|
+
if updates:
|
|
423
|
+
try:
|
|
424
|
+
config = Config.model_validate({**config.model_dump(), **updates})
|
|
425
|
+
except ValidationError as error:
|
|
426
|
+
raise typer.BadParameter(str(error)) from error
|
|
427
|
+
save_config(paths, config)
|
|
428
|
+
result = {
|
|
429
|
+
"server_url": str(config.server_url),
|
|
430
|
+
"machine_id": config.machine_id,
|
|
431
|
+
"machine_name": config.machine_name,
|
|
432
|
+
"upload_interval_seconds": config.upload_interval_seconds,
|
|
433
|
+
"batch_size": config.batch_size,
|
|
434
|
+
"credential_storage": "keyring-or-mode-0600-fallback",
|
|
435
|
+
}
|
|
436
|
+
if json_output:
|
|
437
|
+
_json_print(result)
|
|
438
|
+
else:
|
|
439
|
+
for key, value in result.items():
|
|
440
|
+
typer.echo(f"{key}: {value}")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@emit_app.command("progress")
|
|
444
|
+
def emit_progress(
|
|
445
|
+
current: float = typer.Option(..., "--current"),
|
|
446
|
+
total: float = typer.Option(..., "--total"),
|
|
447
|
+
unit: str | None = typer.Option(None, "--unit"),
|
|
448
|
+
phase: str | None = typer.Option(None, "--phase"),
|
|
449
|
+
message: str | None = typer.Option(None, "--message"),
|
|
450
|
+
) -> None:
|
|
451
|
+
sdk.progress(current, total, unit=unit, phase=phase, message=message)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@emit_app.command("phase")
|
|
455
|
+
def emit_phase(value: str = typer.Argument(...)) -> None:
|
|
456
|
+
sdk.phase(value)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@emit_app.command("message")
|
|
460
|
+
def emit_message(value: str = typer.Argument(...)) -> None:
|
|
461
|
+
sdk.message(value)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
@emit_app.command("attention")
|
|
465
|
+
def emit_attention(
|
|
466
|
+
value: str = typer.Argument(...),
|
|
467
|
+
status: str = typer.Option("ACTION_REQUIRED", "--status"),
|
|
468
|
+
) -> None:
|
|
469
|
+
sdk.attention(value, status=status)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@app.command("_worker", hidden=True)
|
|
473
|
+
def worker_command(
|
|
474
|
+
manifest: Path = typer.Option(..., "--manifest", exists=True, dir_okay=False),
|
|
475
|
+
) -> None:
|
|
476
|
+
raise typer.Exit(run_worker(manifest))
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def main() -> None:
|
|
480
|
+
app()
|
runbuoy/config.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import secrets
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field, HttpUrl
|
|
11
|
+
|
|
12
|
+
from runbuoy.ids import uuid7
|
|
13
|
+
from runbuoy.paths import AppPaths
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Config(BaseModel):
|
|
17
|
+
server_url: HttpUrl = Field(default=HttpUrl("http://127.0.0.1:8000"))
|
|
18
|
+
machine_id: str | None = None
|
|
19
|
+
machine_name: str = Field(default_factory=platform.node)
|
|
20
|
+
upload_interval_seconds: float = Field(default=1.0, ge=0.1, le=60)
|
|
21
|
+
batch_size: int = Field(default=20, ge=1, le=100)
|
|
22
|
+
cancel_grace_seconds: float = Field(default=3.0, ge=0.05, le=60)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_config(paths: AppPaths) -> Config:
|
|
26
|
+
paths.ensure()
|
|
27
|
+
if not paths.config_file.exists():
|
|
28
|
+
return Config()
|
|
29
|
+
return Config.model_validate_json(paths.config_file.read_text(encoding="utf-8"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def save_config(paths: AppPaths, config: Config) -> None:
|
|
33
|
+
paths.ensure()
|
|
34
|
+
paths.config_file.write_text(config.model_dump_json(indent=2), encoding="utf-8")
|
|
35
|
+
paths.config_file.chmod(0o600)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ensure_machine_identity(paths: AppPaths, config: Config) -> Config:
|
|
39
|
+
"""Persist one stable ID before pairing or creating local Runs."""
|
|
40
|
+
if config.machine_id is not None:
|
|
41
|
+
return config
|
|
42
|
+
updated = config.model_copy(update={"machine_id": f"machine_{uuid7().hex}"})
|
|
43
|
+
save_config(paths, updated)
|
|
44
|
+
return updated
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CredentialStore:
|
|
48
|
+
"""Keyring-backed credentials with a mode-0600 fallback."""
|
|
49
|
+
|
|
50
|
+
service = "dev.runbuoy.cli"
|
|
51
|
+
|
|
52
|
+
def __init__(self, paths: AppPaths) -> None:
|
|
53
|
+
self.paths = paths
|
|
54
|
+
|
|
55
|
+
def _keyring(self) -> Any | None:
|
|
56
|
+
if os.environ.get("RUNBUOY_DISABLE_KEYRING") == "1":
|
|
57
|
+
return None
|
|
58
|
+
try:
|
|
59
|
+
import keyring
|
|
60
|
+
|
|
61
|
+
backend = keyring.get_keyring()
|
|
62
|
+
if getattr(backend, "priority", 0) <= 0:
|
|
63
|
+
return None
|
|
64
|
+
return keyring
|
|
65
|
+
except Exception:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
def get(self, name: str) -> str | None:
|
|
69
|
+
backend = self._keyring()
|
|
70
|
+
if backend is not None:
|
|
71
|
+
try:
|
|
72
|
+
return cast(str | None, backend.get_password(self.service, name))
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
return self._read_fallback().get(name)
|
|
76
|
+
|
|
77
|
+
def set(self, name: str, value: str) -> None:
|
|
78
|
+
backend = self._keyring()
|
|
79
|
+
if backend is not None:
|
|
80
|
+
try:
|
|
81
|
+
backend.set_password(self.service, name, value)
|
|
82
|
+
return
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
values = self._read_fallback()
|
|
86
|
+
values[name] = value
|
|
87
|
+
self._write_fallback(values)
|
|
88
|
+
|
|
89
|
+
def delete(self, name: str) -> None:
|
|
90
|
+
backend = self._keyring()
|
|
91
|
+
if backend is not None:
|
|
92
|
+
with suppress(Exception):
|
|
93
|
+
backend.delete_password(self.service, name)
|
|
94
|
+
values = self._read_fallback()
|
|
95
|
+
values.pop(name, None)
|
|
96
|
+
self._write_fallback(values)
|
|
97
|
+
|
|
98
|
+
def _read_fallback(self) -> dict[str, str]:
|
|
99
|
+
path = self.paths.credential_file
|
|
100
|
+
if not path.exists():
|
|
101
|
+
return {}
|
|
102
|
+
if path.stat().st_mode & 0o077:
|
|
103
|
+
raise PermissionError(f"credential file permissions are too broad: {path}")
|
|
104
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
105
|
+
return {str(key): str(value) for key, value in raw.items()}
|
|
106
|
+
|
|
107
|
+
def _write_fallback(self, values: dict[str, str]) -> None:
|
|
108
|
+
self.paths.ensure()
|
|
109
|
+
path = self.paths.credential_file
|
|
110
|
+
path.write_text(json.dumps(values), encoding="utf-8")
|
|
111
|
+
path.chmod(0o600)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def ephemeral_token() -> str:
|
|
115
|
+
return secrets.token_urlsafe(32)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TmuxUnavailableError(RuntimeError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TmuxExecutor:
|
|
15
|
+
@staticmethod
|
|
16
|
+
def available() -> bool:
|
|
17
|
+
return shutil.which("tmux") is not None
|
|
18
|
+
|
|
19
|
+
def start(self, session: str, manifest_path: Path) -> None:
|
|
20
|
+
if not self.available():
|
|
21
|
+
raise TmuxUnavailableError("tmux is required; install tmux and retry")
|
|
22
|
+
environment = os.environ.copy()
|
|
23
|
+
command = [
|
|
24
|
+
"tmux",
|
|
25
|
+
"new-session",
|
|
26
|
+
"-d",
|
|
27
|
+
"-s",
|
|
28
|
+
session,
|
|
29
|
+
"-e",
|
|
30
|
+
f"RUNBUOY_MANIFEST={manifest_path}",
|
|
31
|
+
"-e",
|
|
32
|
+
f"RUNBUOY_PYTHON={sys.executable}",
|
|
33
|
+
]
|
|
34
|
+
# A long-lived tmux server has its own stale environment. Explicitly copy
|
|
35
|
+
# only RunBuoy/XDG path selectors needed to find the same local database.
|
|
36
|
+
for name in (
|
|
37
|
+
"RUNBUOY_HOME",
|
|
38
|
+
"RUNBUOY_SOCKET_DIR",
|
|
39
|
+
"RUNBUOY_DISABLE_KEYRING",
|
|
40
|
+
"XDG_CONFIG_HOME",
|
|
41
|
+
"XDG_DATA_HOME",
|
|
42
|
+
"XDG_STATE_HOME",
|
|
43
|
+
"XDG_CACHE_HOME",
|
|
44
|
+
):
|
|
45
|
+
value = environment.get(name)
|
|
46
|
+
if value is not None:
|
|
47
|
+
command.extend(("-e", f"{name}={value}"))
|
|
48
|
+
command.append('exec "$RUNBUOY_PYTHON" -m runbuoy _worker --manifest "$RUNBUOY_MANIFEST"')
|
|
49
|
+
result = subprocess.run(
|
|
50
|
+
command,
|
|
51
|
+
env=environment,
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
check=False,
|
|
55
|
+
)
|
|
56
|
+
if result.returncode:
|
|
57
|
+
raise RuntimeError(f"tmux could not start worker: {result.stderr.strip()}")
|
|
58
|
+
|
|
59
|
+
def attach(self, session: str) -> int:
|
|
60
|
+
return subprocess.call(["tmux", "attach-session", "-t", session])
|
|
61
|
+
|
|
62
|
+
def exists(self, session: str) -> bool:
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
["tmux", "has-session", "-t", session],
|
|
65
|
+
capture_output=True,
|
|
66
|
+
check=False,
|
|
67
|
+
)
|
|
68
|
+
return result.returncode == 0
|