mulebox 1.1.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.
- mulebox/__init__.py +5 -0
- mulebox/__main__.py +7 -0
- mulebox/cli.py +62 -0
- mulebox/client/__init__.py +1 -0
- mulebox/client/attention.py +156 -0
- mulebox/client/commands.py +304 -0
- mulebox/client/compose_check.py +159 -0
- mulebox/client/compose_yaml.py +78 -0
- mulebox/client/contract.py +284 -0
- mulebox/client/delta.py +91 -0
- mulebox/client/doctor.py +181 -0
- mulebox/client/doctor_commands.py +137 -0
- mulebox/client/events.py +95 -0
- mulebox/client/excludes.py +115 -0
- mulebox/client/feedback.py +165 -0
- mulebox/client/feedback_commands.py +172 -0
- mulebox/client/feedback_guide.py +56 -0
- mulebox/client/gitinfo.py +104 -0
- mulebox/client/init_ci.py +114 -0
- mulebox/client/init_commands.py +89 -0
- mulebox/client/init_detect.py +217 -0
- mulebox/client/init_files.py +45 -0
- mulebox/client/init_langs.py +124 -0
- mulebox/client/job_body.py +74 -0
- mulebox/client/job_commands.py +103 -0
- mulebox/client/job_controls.py +70 -0
- mulebox/client/machine_commands.py +125 -0
- mulebox/client/project_commands.py +76 -0
- mulebox/client/queue_view.py +133 -0
- mulebox/client/remote.py +180 -0
- mulebox/client/report.py +191 -0
- mulebox/client/setup.py +232 -0
- mulebox/client/setup_commands.py +53 -0
- mulebox/client/setup_queued.py +54 -0
- mulebox/client/setup_worker.py +131 -0
- mulebox/client/skill/SKILL.md +137 -0
- mulebox/client/skill_commands.py +87 -0
- mulebox/client/skill_install.py +183 -0
- mulebox/client/snapshot.py +153 -0
- mulebox/client/sshio.py +122 -0
- mulebox/client/stats_commands.py +98 -0
- mulebox/client/submit.py +303 -0
- mulebox/client/summary.py +180 -0
- mulebox/client/take_back.py +94 -0
- mulebox/client/tree.py +153 -0
- mulebox/client/tui.py +113 -0
- mulebox/client/tunnel.py +87 -0
- mulebox/client/webbuild.py +72 -0
- mulebox/client/wheel.py +68 -0
- mulebox/common/__init__.py +1 -0
- mulebox/common/anonymize.py +97 -0
- mulebox/common/compose.py +106 -0
- mulebox/common/config.py +300 -0
- mulebox/common/config_jobs.py +139 -0
- mulebox/common/config_project.py +67 -0
- mulebox/common/config_reader.py +160 -0
- mulebox/common/dashboard_address.py +37 -0
- mulebox/common/disk.py +65 -0
- mulebox/common/download.py +90 -0
- mulebox/common/env_rules.py +88 -0
- mulebox/common/errors.py +9 -0
- mulebox/common/ids.py +102 -0
- mulebox/common/log.py +96 -0
- mulebox/common/manifest.py +63 -0
- mulebox/common/mounts.py +113 -0
- mulebox/common/netdev.py +58 -0
- mulebox/common/network_errors.py +19 -0
- mulebox/common/package_cache.py +15 -0
- mulebox/common/proc.py +132 -0
- mulebox/common/protocol.py +317 -0
- mulebox/common/redact.py +209 -0
- mulebox/common/roots.py +132 -0
- mulebox/common/safepath.py +299 -0
- mulebox/common/ssh.py +285 -0
- mulebox/common/status.py +242 -0
- mulebox/common/take_paths.py +45 -0
- mulebox/common/upgrade.py +30 -0
- mulebox/common/versions.py +21 -0
- mulebox/coordinator/__init__.py +1 -0
- mulebox/coordinator/api.py +282 -0
- mulebox/coordinator/attendance.py +117 -0
- mulebox/coordinator/clients.py +50 -0
- mulebox/coordinator/commands.py +157 -0
- mulebox/coordinator/context.py +30 -0
- mulebox/coordinator/daemon.py +84 -0
- mulebox/coordinator/delta.py +110 -0
- mulebox/coordinator/errors.py +37 -0
- mulebox/coordinator/holds.py +65 -0
- mulebox/coordinator/job_control.py +123 -0
- mulebox/coordinator/leases.py +142 -0
- mulebox/coordinator/lineio.py +32 -0
- mulebox/coordinator/machine_api.py +51 -0
- mulebox/coordinator/migrate.py +131 -0
- mulebox/coordinator/needs.py +96 -0
- mulebox/coordinator/node.py +19 -0
- mulebox/coordinator/project_pause.py +54 -0
- mulebox/coordinator/projects.py +30 -0
- mulebox/coordinator/queue.py +146 -0
- mulebox/coordinator/reach.py +40 -0
- mulebox/coordinator/recovery.py +71 -0
- mulebox/coordinator/reservations.py +151 -0
- mulebox/coordinator/retention.py +56 -0
- mulebox/coordinator/reuse.py +61 -0
- mulebox/coordinator/rpc.py +131 -0
- mulebox/coordinator/runner_api.py +71 -0
- mulebox/coordinator/staging.py +291 -0
- mulebox/coordinator/stats.py +100 -0
- mulebox/coordinator/stats_api.py +44 -0
- mulebox/coordinator/steps.py +72 -0
- mulebox/coordinator/store.py +301 -0
- mulebox/coordinator/taken.py +118 -0
- mulebox/coordinator/upgrades.py +169 -0
- mulebox/coordinator/uploads.py +56 -0
- mulebox/coordinator/usual.py +101 -0
- mulebox/coordinator/validate.py +187 -0
- mulebox/coordinator/views.py +140 -0
- mulebox/coordinator/watch.py +151 -0
- mulebox/coordinator/worker_api.py +122 -0
- mulebox/coordinator/worker_keys.py +109 -0
- mulebox/coordinator/workers.py +110 -0
- mulebox/dashboard/__init__.py +1 -0
- mulebox/dashboard/app/assets/index-DWcl5Bgi.js +51 -0
- mulebox/dashboard/app/assets/index-vRhAVhTS.css +2 -0
- mulebox/dashboard/app/fonts/geist-latin-wght-normal.woff2 +0 -0
- mulebox/dashboard/app/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- mulebox/dashboard/app/index.html +16 -0
- mulebox/dashboard/app/login.css +2 -0
- mulebox/dashboard/app/login.html +25 -0
- mulebox/dashboard/app/theme.js +19 -0
- mulebox/dashboard/cancel.py +99 -0
- mulebox/dashboard/canceller.py +61 -0
- mulebox/dashboard/cert.py +86 -0
- mulebox/dashboard/commands.py +125 -0
- mulebox/dashboard/controls.py +78 -0
- mulebox/dashboard/data.py +157 -0
- mulebox/dashboard/docker.py +89 -0
- mulebox/dashboard/events.py +142 -0
- mulebox/dashboard/firewall.py +68 -0
- mulebox/dashboard/history_query.py +74 -0
- mulebox/dashboard/hub.py +154 -0
- mulebox/dashboard/keys.py +17 -0
- mulebox/dashboard/lan_commands.py +106 -0
- mulebox/dashboard/lanfiles.py +45 -0
- mulebox/dashboard/listeners.py +40 -0
- mulebox/dashboard/login.py +153 -0
- mulebox/dashboard/machine_history.py +67 -0
- mulebox/dashboard/machines.py +94 -0
- mulebox/dashboard/metrics.py +92 -0
- mulebox/dashboard/metrics_api.py +36 -0
- mulebox/dashboard/notices.py +51 -0
- mulebox/dashboard/pages.py +64 -0
- mulebox/dashboard/password.py +74 -0
- mulebox/dashboard/server.py +178 -0
- mulebox/dashboard/session.py +107 -0
- mulebox/dashboard/streams.py +143 -0
- mulebox/machine/__init__.py +0 -0
- mulebox/machine/client.toml.example +16 -0
- mulebox/machine/install.sh +322 -0
- mulebox/machine/node.toml.example +48 -0
- mulebox/machine/systemd/mulebox-coordinator.service +14 -0
- mulebox/machine/systemd/mulebox-dashboard.service +19 -0
- mulebox/machine/systemd/mulebox-runner@.service +27 -0
- mulebox/machine/systemd/mulebox-worker.service +15 -0
- mulebox/runners/__init__.py +1 -0
- mulebox/runners/cancel_run.py +41 -0
- mulebox/runners/commands.py +10 -0
- mulebox/runners/commands_hooks.py +42 -0
- mulebox/runners/commands_manage.py +202 -0
- mulebox/runners/diary.py +110 -0
- mulebox/runners/github.py +111 -0
- mulebox/runners/hooks.py +213 -0
- mulebox/runners/layout.py +120 -0
- mulebox/runners/live_log.py +182 -0
- mulebox/runners/manage.py +225 -0
- mulebox/runners/names.py +70 -0
- mulebox/runners/release.py +130 -0
- mulebox/runners/reservation_file.py +68 -0
- mulebox/runners/runner_caches.py +56 -0
- mulebox/runners/runner_controls.py +81 -0
- mulebox/runners/runner_meter.py +113 -0
- mulebox/runners/supervise.py +166 -0
- mulebox/version.py +40 -0
- mulebox/worker/__init__.py +1 -0
- mulebox/worker/activity.py +81 -0
- mulebox/worker/advert.py +36 -0
- mulebox/worker/agent.py +284 -0
- mulebox/worker/agent_end.py +93 -0
- mulebox/worker/agent_skip.py +55 -0
- mulebox/worker/archive.py +94 -0
- mulebox/worker/background.py +68 -0
- mulebox/worker/caches.py +124 -0
- mulebox/worker/cgroups.py +103 -0
- mulebox/worker/cleaner.py +101 -0
- mulebox/worker/commands.py +159 -0
- mulebox/worker/compose_file.py +179 -0
- mulebox/worker/compose_job.py +130 -0
- mulebox/worker/compose_policy.py +286 -0
- mulebox/worker/controls.py +115 -0
- mulebox/worker/docker.py +65 -0
- mulebox/worker/docker_cleanup.py +101 -0
- mulebox/worker/executor.py +179 -0
- mulebox/worker/install_step.py +52 -0
- mulebox/worker/job.py +57 -0
- mulebox/worker/job_meter.py +109 -0
- mulebox/worker/jobspec.py +121 -0
- mulebox/worker/link.py +110 -0
- mulebox/worker/logtail.py +143 -0
- mulebox/worker/offers.py +65 -0
- mulebox/worker/paths.py +74 -0
- mulebox/worker/ports.py +63 -0
- mulebox/worker/project_commands.py +146 -0
- mulebox/worker/project_disk.py +67 -0
- mulebox/worker/project_home.py +238 -0
- mulebox/worker/pull_failure.py +38 -0
- mulebox/worker/pullnoise.py +118 -0
- mulebox/worker/reclaim.py +82 -0
- mulebox/worker/runspec.py +127 -0
- mulebox/worker/serve.py +45 -0
- mulebox/worker/steps.py +130 -0
- mulebox/worker/systemd.py +180 -0
- mulebox/worker/take_back.py +79 -0
- mulebox/worker/toolchains.py +226 -0
- mulebox/worker/upgrade_wait.py +154 -0
- mulebox/worker/workspace.py +72 -0
- mulebox-1.1.1.dist-info/METADATA +443 -0
- mulebox-1.1.1.dist-info/RECORD +230 -0
- mulebox-1.1.1.dist-info/WHEEL +5 -0
- mulebox-1.1.1.dist-info/entry_points.txt +2 -0
- mulebox-1.1.1.dist-info/licenses/LICENSE +21 -0
- mulebox-1.1.1.dist-info/top_level.txt +1 -0
mulebox/__init__.py
ADDED
mulebox/__main__.py
ADDED
mulebox/cli.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Single entry point: `mulebox <command>`.
|
|
2
|
+
|
|
3
|
+
Commands are registered here as they are implemented (see docs/phases.md). This module only
|
|
4
|
+
parses arguments and dispatches; the work happens in the client, coordinator and worker
|
|
5
|
+
packages.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
from mulebox.client import commands as client_commands
|
|
13
|
+
from mulebox.client import (
|
|
14
|
+
doctor_commands,
|
|
15
|
+
feedback_commands,
|
|
16
|
+
init_commands,
|
|
17
|
+
job_commands,
|
|
18
|
+
job_controls,
|
|
19
|
+
machine_commands,
|
|
20
|
+
project_commands,
|
|
21
|
+
setup_commands,
|
|
22
|
+
skill_commands,
|
|
23
|
+
stats_commands,
|
|
24
|
+
)
|
|
25
|
+
from mulebox.coordinator import commands as coordinator_commands
|
|
26
|
+
from mulebox.dashboard import commands as dashboard_commands
|
|
27
|
+
from mulebox.runners import commands as runner_commands
|
|
28
|
+
from mulebox.version import get_version
|
|
29
|
+
from mulebox.worker import commands as worker_commands
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
33
|
+
parser = argparse.ArgumentParser(
|
|
34
|
+
prog="mulebox",
|
|
35
|
+
description="Run your agents' builds and tests on build machines through one queue.",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument("--version", action="version", version=f"mulebox {get_version()}")
|
|
38
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
39
|
+
sub.add_parser("version", help="print the mulebox version")
|
|
40
|
+
for package in (client_commands, init_commands, job_commands, job_controls, machine_commands,
|
|
41
|
+
project_commands, stats_commands,
|
|
42
|
+
setup_commands, doctor_commands, skill_commands, feedback_commands, coordinator_commands, worker_commands, runner_commands,
|
|
43
|
+
dashboard_commands):
|
|
44
|
+
package.register(sub)
|
|
45
|
+
return parser
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
49
|
+
parser = build_parser()
|
|
50
|
+
args = parser.parse_args(argv)
|
|
51
|
+
if args.command == "version":
|
|
52
|
+
print(get_version())
|
|
53
|
+
return 0
|
|
54
|
+
handler = getattr(args, "func", None)
|
|
55
|
+
if handler is not None:
|
|
56
|
+
return handler(args)
|
|
57
|
+
parser.print_help()
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
sys.exit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""mulebox client (docs/interfaces-m1.md)."""
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""What a person or agent has to hear about a job: questions, and results from while away.
|
|
2
|
+
|
|
3
|
+
Pure text from `status_ok`, `queue_ok` and `submit_ok` bodies, except `warn_here`, which asks
|
|
4
|
+
the coordinator on behalf of a command that does not otherwise see the queue. All of it goes to
|
|
5
|
+
stderr, so `--json` on stdout stays clean. A coordinator too old for any of this sends none of
|
|
6
|
+
these keys and nothing is printed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, TextIO
|
|
13
|
+
|
|
14
|
+
from mulebox.client import report
|
|
15
|
+
from mulebox.client.remote import Remote, request
|
|
16
|
+
from mulebox.client.report import format_duration
|
|
17
|
+
from mulebox.common.config import (
|
|
18
|
+
ConfigError,
|
|
19
|
+
find_project_config,
|
|
20
|
+
load_client_config,
|
|
21
|
+
load_project_config,
|
|
22
|
+
)
|
|
23
|
+
from mulebox.common.errors import MuleboxError
|
|
24
|
+
from mulebox.common.network_errors import LOST
|
|
25
|
+
from mulebox.common.protocol import MessageType
|
|
26
|
+
from mulebox.common import disk, status
|
|
27
|
+
from mulebox.common.status import FETCHING, number
|
|
28
|
+
|
|
29
|
+
WARNING = "warning: "
|
|
30
|
+
QUEUE_NOTHING_FINISHED = 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _name(job: dict[str, Any]) -> str:
|
|
34
|
+
known = [str(job[key]) for key in ("project", "job") if job.get(key)]
|
|
35
|
+
return f" ({' '.join(known)})" if known else ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def stops_in(view: dict[str, Any]) -> str:
|
|
39
|
+
"""When a job stops if it stays stuck. A working job does not: this time keeps moving."""
|
|
40
|
+
return f"stops in {format_duration(number(view.get('left_s')))} if it stays stuck"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def question_line(job_id: str, view: dict[str, Any], job: dict[str, Any] | None = None) -> str:
|
|
44
|
+
"""The reminder of a question not answered yet; it names both answers."""
|
|
45
|
+
return (f"{WARNING}job {job_id}{_name(job or {})} has run a long time: is everything OK? "
|
|
46
|
+
f"While it works it goes on; it {stops_in(view)}. Yes: mulebox ok {job_id}. "
|
|
47
|
+
f"No: mulebox cancel {job_id}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def result_words(job: dict[str, Any]) -> str:
|
|
51
|
+
code = job.get("exit_code")
|
|
52
|
+
words = str(status.outcome_of(job)) + (f", exit {code}" if code is not None else "")
|
|
53
|
+
commit = job.get("base_commit")
|
|
54
|
+
return f"{words}, on commit {str(commit)[:10]}" if commit else words
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def away_line(job: dict[str, Any]) -> str:
|
|
58
|
+
"""A result nobody received: the agent need not run the job again."""
|
|
59
|
+
job_id = job.get("job_id")
|
|
60
|
+
return (f"job {job_id}{_name(job)} finished while you were away: {result_words(job)}. "
|
|
61
|
+
f"No need to retest; its log: mulebox logs {job_id}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def already_line(job: dict[str, Any]) -> str:
|
|
65
|
+
"""`check` found the same job on the same files."""
|
|
66
|
+
job_id = job.get("job_id")
|
|
67
|
+
if job.get("state") != "finished":
|
|
68
|
+
return f"this exact job is already {job.get('state')} as {job_id}: following that one"
|
|
69
|
+
return (f"already tested: job {job_id}{_name(job)} ran on exactly these files: "
|
|
70
|
+
f"{result_words(job)}. Not run again; to force a run: --again. "
|
|
71
|
+
f"Its log: mulebox logs {job_id}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def activity_text(activity: dict[str, Any]) -> str:
|
|
75
|
+
"""The readings there are; the worker leaves out what it could not measure."""
|
|
76
|
+
parts = []
|
|
77
|
+
if activity.get("stage"):
|
|
78
|
+
parts.append(f"stage {activity['stage']}")
|
|
79
|
+
quiet = number(activity.get("output_idle_s"))
|
|
80
|
+
if quiet is not None:
|
|
81
|
+
parts.append(f"last output {format_duration(quiet)} ago")
|
|
82
|
+
job_cpu = number(activity.get("job_cpu_pct"))
|
|
83
|
+
if job_cpu is not None:
|
|
84
|
+
parts.append(f"job CPU {job_cpu:.0f}% of a core")
|
|
85
|
+
cpu, net = number(activity.get("cpu_pct")), number(activity.get("net_kib_s"))
|
|
86
|
+
if cpu is not None:
|
|
87
|
+
parts.append(f"machine CPU {cpu:.0f}% of a core")
|
|
88
|
+
if net is not None:
|
|
89
|
+
parts.append(f"machine network {net:.1f} KiB/s")
|
|
90
|
+
if activity.get("last_line"):
|
|
91
|
+
parts.append(f"last line: {activity['last_line']}")
|
|
92
|
+
return ", ".join(parts) or "no readings from the machine"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def check_in_line(job_id: str, check_in: dict[str, Any]) -> str:
|
|
96
|
+
took = format_duration(number(check_in.get("elapsed_s")))
|
|
97
|
+
return (f"{WARNING}check-in: job {job_id} has run {took}; {activity_text(check_in)}. "
|
|
98
|
+
f"Is everything OK? Yes: mulebox ok {job_id}. No: mulebox cancel {job_id}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def notice(job_id: str, body: dict[str, Any]) -> tuple[Any, str] | None:
|
|
102
|
+
"""(a key that is the same on a reconnect, the line) for a body that carries a notice."""
|
|
103
|
+
check_in = body.get("check_in")
|
|
104
|
+
if isinstance(check_in, dict):
|
|
105
|
+
return ("check_in", check_in.get("number")), check_in_line(job_id, check_in)
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def network_trouble(activity: dict[str, Any]) -> bool:
|
|
110
|
+
"""Whether a job that is fetching things printed a lost connection last. Not in `command`:
|
|
111
|
+
a project's own tests can print that on purpose."""
|
|
112
|
+
line = activity.get("last_line")
|
|
113
|
+
return activity.get("stage") in FETCHING and bool(line and LOST.search(str(line)))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def status_lines(body: dict[str, Any]) -> list[str]:
|
|
117
|
+
"""`mulebox status`: what the job is doing and what it is waiting to hear."""
|
|
118
|
+
job_id, lines = str(body.get("job_id")), []
|
|
119
|
+
activity, view = body.get("activity"), body.get("question")
|
|
120
|
+
if isinstance(activity, dict):
|
|
121
|
+
took = format_duration(number(activity.get("elapsed_s")))
|
|
122
|
+
lines.append(f" activity: running {took}, {activity_text(activity)}")
|
|
123
|
+
if network_trouble(activity):
|
|
124
|
+
lines.append(" warning: its last line reads as a lost connection; downloads may be "
|
|
125
|
+
"retrying, and this is the machine's network, not the project")
|
|
126
|
+
if isinstance(view, dict):
|
|
127
|
+
lines.append(f" question: is everything OK? It {stops_in(view)}. "
|
|
128
|
+
f"Yes: mulebox ok {job_id}. No: mulebox cancel {job_id}")
|
|
129
|
+
return lines
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def tell(body: dict[str, Any], err: TextIO) -> None:
|
|
133
|
+
"""Questions waiting for an answer and results from while away, from a reply's body."""
|
|
134
|
+
for job in _dicts(body.get("questions")):
|
|
135
|
+
report.status(err, question_line(str(job.get("job_id")), job.get("question") or {}, job))
|
|
136
|
+
for job in _dicts(body.get("finished_away")):
|
|
137
|
+
report.status(err, away_line(job))
|
|
138
|
+
for low in _dicts(body.get("low_disks")):
|
|
139
|
+
report.status(err, WARNING + disk.warning(str(low.get("worker")), int(low.get("free") or 0)))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _dicts(items: Any) -> list[dict[str, Any]]:
|
|
143
|
+
return [item for item in items if isinstance(item, dict)] if isinstance(items, list) else []
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def tell_here(err: TextIO, folder: Path | None = None) -> None:
|
|
147
|
+
"""Tell this project's news. Only a remark on another command's way out: nothing that goes
|
|
148
|
+
wrong here may change what that command returned."""
|
|
149
|
+
try:
|
|
150
|
+
project = load_project_config(find_project_config(folder or Path.cwd())).project
|
|
151
|
+
remote = Remote(load_client_config())
|
|
152
|
+
reply = remote.call(request(MessageType.QUEUE, limit=QUEUE_NOTHING_FINISHED,
|
|
153
|
+
news_for=project))
|
|
154
|
+
except (ConfigError, MuleboxError, OSError):
|
|
155
|
+
return
|
|
156
|
+
tell(reply.body, err)
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""CLI subcommands of the client package (docs/interfaces-m1.md, CLI).
|
|
2
|
+
|
|
3
|
+
Exit codes for client failures: 2 usage/config error, 3 build machine unreachable,
|
|
4
|
+
4 protocol/remote error, 5 job could not start, 76 stopped as stuck with no answer from the agent,
|
|
5
|
+
130 Ctrl-C. Job failures return the job's own exit code.
|
|
6
|
+
A build machine that cannot be reached is reported as such; mulebox never runs the job
|
|
7
|
+
anywhere else instead.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from mulebox.client import (attention, contract, queue_view, report, skill_install, summary,
|
|
20
|
+
take_back, tui)
|
|
21
|
+
from mulebox.client.remote import CoordinatorError, Remote, request
|
|
22
|
+
from mulebox.client.snapshot import SnapshotError
|
|
23
|
+
from mulebox.client.submit import Streams, Watcher, WatchLost, run_job
|
|
24
|
+
from mulebox.common.config import (
|
|
25
|
+
ConfigError,
|
|
26
|
+
ProjectConfig,
|
|
27
|
+
ProjectNotFound,
|
|
28
|
+
find_project_config,
|
|
29
|
+
load_client_config,
|
|
30
|
+
load_project_config,
|
|
31
|
+
)
|
|
32
|
+
from mulebox.common.config_jobs import BARE
|
|
33
|
+
from mulebox.common.errors import MuleboxError
|
|
34
|
+
from mulebox.common.protocol import MessageType, ProtocolError
|
|
35
|
+
from mulebox.common.redact import Redactor
|
|
36
|
+
from mulebox.common.ssh import RemoteTimeout, SSHError, SSHUnavailable
|
|
37
|
+
from mulebox.version import get_version
|
|
38
|
+
|
|
39
|
+
QUEUE_LIMIT = 20
|
|
40
|
+
Handler = Callable[[argparse.Namespace], int]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class UsageError(MuleboxError):
|
|
44
|
+
"""The command line asks for something that does not exist."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def register(sub: argparse._SubParsersAction) -> None:
|
|
48
|
+
"""Add this package's subcommands. Each sets func=handler(args) -> int."""
|
|
49
|
+
check = sub.add_parser("check", help="run a job from .mulebox.yml on a build machine")
|
|
50
|
+
check.add_argument("job", nargs="?", default="check", help="job name (default: check)")
|
|
51
|
+
check.add_argument("--detach", action="store_true",
|
|
52
|
+
help="print the job id once it is queued and leave it running")
|
|
53
|
+
check.add_argument("--again", action="store_true",
|
|
54
|
+
help="run even if these exact files were already tested")
|
|
55
|
+
check.set_defaults(func=guarded(_check))
|
|
56
|
+
|
|
57
|
+
warm = sub.add_parser("warm", help="prepare a job ahead of time: toolchains, images, caches, "
|
|
58
|
+
"install; no command")
|
|
59
|
+
warm.add_argument("job", nargs="?", default="check", help="job name (default: check)")
|
|
60
|
+
warm.add_argument("--detach", action="store_true",
|
|
61
|
+
help="print the job id once it is queued and leave it running")
|
|
62
|
+
warm.set_defaults(func=guarded(_warm, notices=True))
|
|
63
|
+
|
|
64
|
+
run = sub.add_parser("run", help="run an ad-hoc command on a build machine")
|
|
65
|
+
run.add_argument("--detach", action="store_true",
|
|
66
|
+
help="print the job id once it is queued and leave it running")
|
|
67
|
+
run.add_argument("--in", dest="service", metavar="SERVICE",
|
|
68
|
+
help="run it inside this compose service")
|
|
69
|
+
run.add_argument("--bare", action="store_true",
|
|
70
|
+
help="the command alone: no containers, install or setup")
|
|
71
|
+
run.add_argument("--take", action="append", default=[], metavar="PATH",
|
|
72
|
+
help="once the command passed, copy this file back into the project "
|
|
73
|
+
"(repeat for more), e.g. --take api/composer.lock")
|
|
74
|
+
run.add_argument("argv", nargs=argparse.REMAINDER, help="-- command and arguments")
|
|
75
|
+
run.set_defaults(func=guarded(_run))
|
|
76
|
+
|
|
77
|
+
queue = sub.add_parser("queue", help="list running, waiting and recent jobs")
|
|
78
|
+
queue.add_argument("--watch", action="store_true", help="full-screen live view")
|
|
79
|
+
queue.set_defaults(func=guarded(_queue))
|
|
80
|
+
|
|
81
|
+
known = sub.add_parser("projects", help="list every project that has run here")
|
|
82
|
+
known.add_argument("--json", action="store_true", help="stable keys for tools")
|
|
83
|
+
known.set_defaults(func=guarded(_projects, notices=True))
|
|
84
|
+
|
|
85
|
+
logs = sub.add_parser("logs", help="show a job's log")
|
|
86
|
+
logs.add_argument("job_id")
|
|
87
|
+
logs.add_argument("-f", "--follow", action="store_true", help="follow until it finishes")
|
|
88
|
+
logs.add_argument("--summary", action="store_true",
|
|
89
|
+
help="only why it failed: stage, exit code, last relevant lines")
|
|
90
|
+
logs.set_defaults(func=guarded(_logs, notices=True))
|
|
91
|
+
|
|
92
|
+
cancel = sub.add_parser("cancel", help="cancel a job")
|
|
93
|
+
cancel.add_argument("job_id")
|
|
94
|
+
cancel.set_defaults(func=guarded(_cancel, notices=True))
|
|
95
|
+
|
|
96
|
+
terms = sub.add_parser("contract", help="print how to work in this project through "
|
|
97
|
+
"mulebox, for agents")
|
|
98
|
+
terms.add_argument("--json", action="store_true", help="stable keys for tools")
|
|
99
|
+
terms.set_defaults(func=guarded(_contract))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def guarded(handler: Handler, notices: bool = False) -> Handler:
|
|
103
|
+
"""Turn expected errors into one clear stderr line and the contract's exit code. `notices`:
|
|
104
|
+
a command that reached the machine also tells this project's questions and results."""
|
|
105
|
+
|
|
106
|
+
def run(args: argparse.Namespace) -> int:
|
|
107
|
+
try:
|
|
108
|
+
code = handler(args)
|
|
109
|
+
if notices:
|
|
110
|
+
attention.tell_here(sys.stderr)
|
|
111
|
+
return code
|
|
112
|
+
except KeyboardInterrupt:
|
|
113
|
+
return report.EXIT_CANCELLED
|
|
114
|
+
except (ConfigError, SnapshotError, UsageError) as exc:
|
|
115
|
+
return _fail(str(exc), report.EXIT_USAGE)
|
|
116
|
+
except WatchLost as exc:
|
|
117
|
+
return _fail(str(exc), report.EXIT_UNREACHABLE)
|
|
118
|
+
except (SSHUnavailable, RemoteTimeout) as exc:
|
|
119
|
+
return _fail(
|
|
120
|
+
f"build machine unreachable: {exc} The job was not run; "
|
|
121
|
+
"fix the connection to the build machine and retry.",
|
|
122
|
+
report.EXIT_UNREACHABLE,
|
|
123
|
+
)
|
|
124
|
+
except (CoordinatorError, ProtocolError, SSHError) as exc:
|
|
125
|
+
return _fail(str(exc), report.EXIT_REMOTE)
|
|
126
|
+
|
|
127
|
+
return run
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _fail(message: str, code: int) -> int:
|
|
131
|
+
report.status(sys.stderr, f"error: {Redactor().redact(message)}")
|
|
132
|
+
return code
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def project_here() -> tuple[ProjectConfig, Path]:
|
|
136
|
+
path = find_project_config(Path.cwd())
|
|
137
|
+
return load_project_config(path), path.parent
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def connect() -> Remote:
|
|
141
|
+
return Remote(load_client_config())
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _contract(args: argparse.Namespace) -> int:
|
|
145
|
+
try:
|
|
146
|
+
config = project_here()[0]
|
|
147
|
+
except ProjectNotFound:
|
|
148
|
+
config = None
|
|
149
|
+
terms = contract.build(config, get_version())
|
|
150
|
+
if stale := skill_install.stale_here():
|
|
151
|
+
report.status(sys.stderr, stale)
|
|
152
|
+
sys.stdout.write(json.dumps(terms, indent=2) + "\n" if args.json
|
|
153
|
+
else contract.render(terms))
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _named_job(name: str) -> tuple[ProjectConfig, Path]:
|
|
158
|
+
project, root = project_here()
|
|
159
|
+
if name not in project.jobs:
|
|
160
|
+
names = ", ".join(sorted(project.jobs))
|
|
161
|
+
raise UsageError(f"no job {name!r} in .mulebox.yml (jobs: {names})")
|
|
162
|
+
return project, root
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _check(args: argparse.Namespace) -> int:
|
|
166
|
+
project, root = _named_job(args.job)
|
|
167
|
+
return run_job(project, args.job, root, connect(), detach=args.detach,
|
|
168
|
+
again=args.again)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _warm(args: argparse.Namespace) -> int:
|
|
172
|
+
project, root = _named_job(args.job)
|
|
173
|
+
return run_job(project, args.job, root, connect(), detach=args.detach, warm=True)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _run(args: argparse.Namespace) -> int:
|
|
177
|
+
argv = list(args.argv)
|
|
178
|
+
if argv and argv[0] == "--":
|
|
179
|
+
argv = argv[1:]
|
|
180
|
+
if not argv:
|
|
181
|
+
raise UsageError("nothing to run; use `mulebox run -- <command> [args...]`")
|
|
182
|
+
project, root = project_here()
|
|
183
|
+
if args.service:
|
|
184
|
+
if args.bare:
|
|
185
|
+
raise UsageError("--in runs in a service, which --bare does not start")
|
|
186
|
+
if not project.compose:
|
|
187
|
+
raise UsageError("--in needs `compose:` in .mulebox.yml")
|
|
188
|
+
argv = ["docker", "compose", "exec", "-T", args.service, *argv]
|
|
189
|
+
if args.take and args.detach:
|
|
190
|
+
raise UsageError("--take brings files back at the end, so it waits; drop --detach")
|
|
191
|
+
try:
|
|
192
|
+
take = take_back.check(args.take, project.exclude)
|
|
193
|
+
except ValueError as exc:
|
|
194
|
+
raise UsageError(str(exc)) from None
|
|
195
|
+
return run_job(project, argv, root, connect(), detach=args.detach,
|
|
196
|
+
options=BARE if args.bare else None, take=take)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _queue(args: argparse.Namespace) -> int:
|
|
200
|
+
remote = connect()
|
|
201
|
+
latest: dict[str, Any] = {}
|
|
202
|
+
|
|
203
|
+
def fetch() -> list[dict]:
|
|
204
|
+
reply = remote.call(request(MessageType.QUEUE, limit=QUEUE_LIMIT))
|
|
205
|
+
latest.update(reply.body)
|
|
206
|
+
jobs = reply.body.get("jobs")
|
|
207
|
+
if not isinstance(jobs, list):
|
|
208
|
+
raise CoordinatorError("the queue reply has no job list")
|
|
209
|
+
return [j for j in jobs if isinstance(j, dict)]
|
|
210
|
+
|
|
211
|
+
if args.watch:
|
|
212
|
+
return tui.watch(fetch, remote.target)
|
|
213
|
+
tui.show_once(fetch, sys.stdout)
|
|
214
|
+
report.dashboard_hint(sys.stderr, latest)
|
|
215
|
+
return 0
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _with_folders(remote: Remote, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
219
|
+
"""`rows` with each project's folder and size; the job history alone from an older machine."""
|
|
220
|
+
try:
|
|
221
|
+
folders = json.loads(remote.project_home("list", "--json").stdout)
|
|
222
|
+
except (MuleboxError, ValueError):
|
|
223
|
+
return rows
|
|
224
|
+
known = {row["project"]: row for row in folders}
|
|
225
|
+
return [{**row, **{k: v for k, v in known.get(row["project"], {}).items() if k != "project"}}
|
|
226
|
+
for row in rows]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _projects(args: argparse.Namespace) -> int:
|
|
230
|
+
remote = connect()
|
|
231
|
+
rows = remote.call(request(MessageType.PROJECTS)).body.get("projects")
|
|
232
|
+
if not isinstance(rows, list):
|
|
233
|
+
raise CoordinatorError("the projects reply has no project list")
|
|
234
|
+
rows = _with_folders(remote, rows)
|
|
235
|
+
if args.json:
|
|
236
|
+
sys.stdout.write(json.dumps(rows, indent=2) + "\n")
|
|
237
|
+
else:
|
|
238
|
+
sys.stdout.write("\n".join(queue_view.render_projects(rows)) + "\n")
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _logs(args: argparse.Namespace) -> int:
|
|
243
|
+
remote = connect()
|
|
244
|
+
watcher = Watcher(remote, Streams())
|
|
245
|
+
watcher.job_id = args.job_id
|
|
246
|
+
if args.summary:
|
|
247
|
+
return _log_summary(remote, args.job_id)
|
|
248
|
+
if args.follow:
|
|
249
|
+
result = watcher.attach()
|
|
250
|
+
note_expired_log(watcher)
|
|
251
|
+
watcher.finish(result)
|
|
252
|
+
return report.exit_code_for(result)
|
|
253
|
+
_read_log(remote, args.job_id, 0, watcher.write_log)
|
|
254
|
+
note_expired_log(watcher)
|
|
255
|
+
return 0
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def note_expired_log(watcher: Watcher) -> None:
|
|
259
|
+
"""A log that printed nothing may be gone rather than empty. Only a remark: losing the
|
|
260
|
+
connection here must not cost `wait` the job's exit code."""
|
|
261
|
+
if watcher.offset:
|
|
262
|
+
return
|
|
263
|
+
try:
|
|
264
|
+
body = watcher.remote.call(request(MessageType.STATUS, job_id=watcher.job_id)).body
|
|
265
|
+
except (MuleboxError, OSError):
|
|
266
|
+
return
|
|
267
|
+
if report.log_expired(body):
|
|
268
|
+
report.status(watcher.io.err, report.expired_line(watcher.job_id))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _read_log(remote: Remote, job_id: str, offset: int, sink: Callable[[dict], None]) -> None:
|
|
272
|
+
while True:
|
|
273
|
+
reply = remote.call(request(MessageType.LOGS, job_id=job_id, offset=offset))
|
|
274
|
+
sink(reply.body)
|
|
275
|
+
next_offset = reply.body.get("next_offset", offset)
|
|
276
|
+
if reply.body.get("eof", True) or next_offset == offset:
|
|
277
|
+
return
|
|
278
|
+
offset = next_offset
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _log_summary(remote: Remote, job_id: str) -> int:
|
|
282
|
+
body = remote.call(request(MessageType.STATUS, job_id=job_id)).body
|
|
283
|
+
state = body.get("state")
|
|
284
|
+
if state != "finished":
|
|
285
|
+
print(f"job {job_id} is {state}; a summary needs it to end")
|
|
286
|
+
elif not summary.wanted(body):
|
|
287
|
+
print(report.outcome_line(job_id, body))
|
|
288
|
+
else:
|
|
289
|
+
tail = summary.LogTail()
|
|
290
|
+
size = body.get("log_size")
|
|
291
|
+
start = max(0, size - summary.TAIL_BYTES) if isinstance(size, int) else 0
|
|
292
|
+
tail.cut = start > 0
|
|
293
|
+
_read_log(remote, job_id, start, lambda chunk: tail.feed(chunk.get("data") or ""))
|
|
294
|
+
if not tail.text and report.log_expired(body):
|
|
295
|
+
report.status(sys.stderr, report.expired_line(job_id))
|
|
296
|
+
print("\n".join(summary.render(job_id, body, summary.relevant_lines(tail))))
|
|
297
|
+
return 0
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _cancel(args: argparse.Namespace) -> int:
|
|
301
|
+
reply = connect().call(request(MessageType.CANCEL, job_id=args.job_id))
|
|
302
|
+
state = reply.body.get("state", "unknown")
|
|
303
|
+
report.status(sys.stderr, f"cancel requested for job {args.job_id} (state {state})")
|
|
304
|
+
return 0
|