gini-toolkit 6.0.1.dev0__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.
- gini/__init__.py +12 -0
- gini/__main__.py +107 -0
- gini/_version.py +24 -0
- gini/agent/__init__.py +17 -0
- gini/agent/agent_gamemaster.py +140 -0
- gini/agent/api.py +291 -0
- gini/agent/ask.py +123 -0
- gini/agent/authoring.py +72 -0
- gini/agent/blackboard.py +114 -0
- gini/agent/contracts.py +142 -0
- gini/agent/domains.py +91 -0
- gini/agent/embed.py +123 -0
- gini/agent/gamemaster.py +256 -0
- gini/agent/kb.py +148 -0
- gini/agent/lesson_resolver.py +261 -0
- gini/agent/llm/__init__.py +5 -0
- gini/agent/llm/backend.py +43 -0
- gini/agent/llm/fake.py +25 -0
- gini/agent/llm/ollama.py +206 -0
- gini/agent/loop.py +258 -0
- gini/agent/mcp_server.py +86 -0
- gini/agent/meaning.py +225 -0
- gini/agent/mission.py +210 -0
- gini/agent/mission_controller.py +208 -0
- gini/agent/narration.py +116 -0
- gini/agent/notifier.py +86 -0
- gini/agent/personas.py +79 -0
- gini/agent/reasoning.py +172 -0
- gini/agent/recall.py +248 -0
- gini/agent/session.py +79 -0
- gini/agent/teaching_center.py +482 -0
- gini/agent/tools/__init__.py +3 -0
- gini/agent/tools/registry.py +193 -0
- gini/agent/twin/__init__.py +28 -0
- gini/agent/twin/authoring.py +71 -0
- gini/agent/twin/contracts.py +54 -0
- gini/agent/twin/dialectic.py +189 -0
- gini/agent/twin/harness.py +93 -0
- gini/agent/twin/justify.py +156 -0
- gini/agent/twin/learner.py +64 -0
- gini/agent/twin/mission.py +60 -0
- gini/agent/twin/os_coach.py +79 -0
- gini/agent/twin/salience.py +30 -0
- gini/agent/understand.py +250 -0
- gini/agent/verifiers.py +106 -0
- gini/agent/wizard.py +178 -0
- gini/agent/xv6_pack.py +74 -0
- gini/app/__init__.py +3 -0
- gini/app/context.py +368 -0
- gini/app/paths.py +121 -0
- gini/data/README.md +21 -0
- gini/domain/__init__.py +9 -0
- gini/domain/assembly.py +209 -0
- gini/domain/authoring.py +353 -0
- gini/domain/blueprints.py +5 -0
- gini/domain/capabilities.py +177 -0
- gini/domain/catalog.py +85 -0
- gini/domain/certify.py +201 -0
- gini/domain/compose.py +413 -0
- gini/domain/composition.py +88 -0
- gini/domain/concepts.py +383 -0
- gini/domain/connection_rules.py +269 -0
- gini/domain/constraints.py +153 -0
- gini/domain/content.py +59 -0
- gini/domain/cpu_journey.py +89 -0
- gini/domain/devices.py +747 -0
- gini/domain/diagnose.py +201 -0
- gini/domain/element_guide.py +327 -0
- gini/domain/explain.py +90 -0
- gini/domain/fingerprint.py +201 -0
- gini/domain/firewall.py +34 -0
- gini/domain/flowlog.py +61 -0
- gini/domain/flowtable.py +179 -0
- gini/domain/fragment_yaml.py +230 -0
- gini/domain/fragments.py +169 -0
- gini/domain/games/__init__.py +2 -0
- gini/domain/games/paging_games.py +119 -0
- gini/domain/games/policy_game.py +86 -0
- gini/domain/games/process_game.py +48 -0
- gini/domain/games/thrash_game.py +75 -0
- gini/domain/games/translate_game.py +60 -0
- gini/domain/games/trap_game.py +86 -0
- gini/domain/grader.py +155 -0
- gini/domain/grouping.py +67 -0
- gini/domain/legality.py +103 -0
- gini/domain/lesson.py +241 -0
- gini/domain/lexicon.py +150 -0
- gini/domain/machine_state.py +410 -0
- gini/domain/missions/networking/basic-lan.yaml +32 -0
- gini/domain/missions/networking/cache-in-front.yaml +23 -0
- gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
- gini/domain/missions/networking/drive-load.yaml +20 -0
- gini/domain/missions/networking/fix-the-address.yaml +75 -0
- gini/domain/missions/networking/fix-the-lan.yaml +43 -0
- gini/domain/missions/networking/inspect-flows.yaml +16 -0
- gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
- gini/domain/missions/networking/least-privilege.yaml +21 -0
- gini/domain/missions/networking/load-balanced-web.yaml +29 -0
- gini/domain/missions/networking/observe-it.yaml +24 -0
- gini/domain/missions/networking/put-in-vpc.yaml +30 -0
- gini/domain/missions/networking/reachability-boundary.yaml +56 -0
- gini/domain/missions/networking/sdn-reactive.yaml +35 -0
- gini/domain/missions/networking/send-request.yaml +19 -0
- gini/domain/missions/networking/serverless-api.yaml +25 -0
- gini/domain/missions/networking/service-chain.yaml +33 -0
- gini/domain/missions/os/lottery-fix.yaml +19 -0
- gini/domain/missions/os/priority-fix.yaml +24 -0
- gini/domain/missions.py +111 -0
- gini/domain/modulechain.py +36 -0
- gini/domain/objectives.py +488 -0
- gini/domain/os_zoo.py +79 -0
- gini/domain/paging_sim.py +141 -0
- gini/domain/pricing.py +199 -0
- gini/domain/probes.py +226 -0
- gini/domain/profile.py +142 -0
- gini/domain/recipes.py +738 -0
- gini/domain/riders.py +309 -0
- gini/domain/router_modules.py +224 -0
- gini/domain/routetable.py +67 -0
- gini/domain/scoring.py +76 -0
- gini/domain/staging.py +122 -0
- gini/domain/syscall_builder.py +144 -0
- gini/domain/topic_cloud.py +62 -0
- gini/domain/topology.py +213 -0
- gini/domain/vocabulary.py +51 -0
- gini/domain/xv6.py +808 -0
- gini/domain/xv6_fs.py +250 -0
- gini/domain/xv6_runner.py +113 -0
- gini/domain/xv6_vm.py +385 -0
- gini/gloader.py +17 -0
- gini/runtime/__init__.py +18 -0
- gini/runtime/cloudfabric_agent.py +370 -0
- gini/runtime/console.py +68 -0
- gini/runtime/control.py +70 -0
- gini/runtime/frame.py +138 -0
- gini/runtime/gbridge.py +638 -0
- gini/runtime/grouter.py +223 -0
- gini/runtime/hostsim.py +90 -0
- gini/runtime/shuttle.py +348 -0
- gini/runtime/switch.py +109 -0
- gini/runtime/transport.py +77 -0
- gini/runtime/xv6_bridge.py +312 -0
- gini/server/__init__.py +22 -0
- gini/server/__main__.py +74 -0
- gini/server/app.py +140 -0
- gini/server/auth.py +82 -0
- gini/server/policy.py +57 -0
- gini/server/session.py +23 -0
- gini/services/__init__.py +15 -0
- gini/services/boardflash.py +248 -0
- gini/services/boardsetup.py +374 -0
- gini/services/cloud_catalog.py +143 -0
- gini/services/compiler.py +1858 -0
- gini/services/discovery.py +324 -0
- gini/services/gloader.py +183 -0
- gini/services/orchestrator.py +1460 -0
- gini/services/persistence.py +28 -0
- gini/services/probe_runner.py +149 -0
- gini/services/project.py +217 -0
- gini/services/remote.py +93 -0
- gini/services/rider_runner.py +96 -0
- gini/services/rider_session.py +171 -0
- gini/services/shadow_store.py +52 -0
- gini/services/terminal.py +45 -0
- gini/setup/__init__.py +17 -0
- gini/setup/cli.py +109 -0
- gini/setup/images.py +33 -0
- gini/setup/marker.py +43 -0
- gini/setup/runtime.py +69 -0
- gini/ui/__init__.py +3 -0
- gini/ui/assets/app_icon.icns +0 -0
- gini/ui/assets/app_icon.ico +0 -0
- gini/ui/assets/app_icon.png +0 -0
- gini/ui/assets/app_icon_1024.png +0 -0
- gini/ui/assets/cue/_w.txt +1 -0
- gini/ui/assets/cue/ai.png +0 -0
- gini/ui/assets/cue/canvas.png +0 -0
- gini/ui/assets/cue/cloud.png +0 -0
- gini/ui/assets/cue/cost.png +0 -0
- gini/ui/assets/cue/dark/ai.png +0 -0
- gini/ui/assets/cue/dark/canvas.png +0 -0
- gini/ui/assets/cue/dark/cloud.png +0 -0
- gini/ui/assets/cue/dark/cost.png +0 -0
- gini/ui/assets/cue/dark/metrics.png +0 -0
- gini/ui/assets/cue/dark/router.png +0 -0
- gini/ui/assets/cue/dark/run.png +0 -0
- gini/ui/assets/cue/dark/serverless.png +0 -0
- gini/ui/assets/cue/dark/settings.png +0 -0
- gini/ui/assets/cue/dark/welcome.png +0 -0
- gini/ui/assets/cue/dark/wizard.png +0 -0
- gini/ui/assets/cue/ginibrand/ai.png +0 -0
- gini/ui/assets/cue/ginibrand/canvas.png +0 -0
- gini/ui/assets/cue/ginibrand/cloud.png +0 -0
- gini/ui/assets/cue/ginibrand/cost.png +0 -0
- gini/ui/assets/cue/ginibrand/metrics.png +0 -0
- gini/ui/assets/cue/ginibrand/router.png +0 -0
- gini/ui/assets/cue/ginibrand/run.png +0 -0
- gini/ui/assets/cue/ginibrand/serverless.png +0 -0
- gini/ui/assets/cue/ginibrand/settings.png +0 -0
- gini/ui/assets/cue/ginibrand/welcome.png +0 -0
- gini/ui/assets/cue/ginibrand/wizard.png +0 -0
- gini/ui/assets/cue/highcontrast/ai.png +0 -0
- gini/ui/assets/cue/highcontrast/canvas.png +0 -0
- gini/ui/assets/cue/highcontrast/cloud.png +0 -0
- gini/ui/assets/cue/highcontrast/cost.png +0 -0
- gini/ui/assets/cue/highcontrast/metrics.png +0 -0
- gini/ui/assets/cue/highcontrast/router.png +0 -0
- gini/ui/assets/cue/highcontrast/run.png +0 -0
- gini/ui/assets/cue/highcontrast/serverless.png +0 -0
- gini/ui/assets/cue/highcontrast/settings.png +0 -0
- gini/ui/assets/cue/highcontrast/welcome.png +0 -0
- gini/ui/assets/cue/highcontrast/wizard.png +0 -0
- gini/ui/assets/cue/light/ai.png +0 -0
- gini/ui/assets/cue/light/canvas.png +0 -0
- gini/ui/assets/cue/light/cloud.png +0 -0
- gini/ui/assets/cue/light/cost.png +0 -0
- gini/ui/assets/cue/light/metrics.png +0 -0
- gini/ui/assets/cue/light/router.png +0 -0
- gini/ui/assets/cue/light/run.png +0 -0
- gini/ui/assets/cue/light/serverless.png +0 -0
- gini/ui/assets/cue/light/settings.png +0 -0
- gini/ui/assets/cue/light/welcome.png +0 -0
- gini/ui/assets/cue/light/wizard.png +0 -0
- gini/ui/assets/cue/metrics.png +0 -0
- gini/ui/assets/cue/router.png +0 -0
- gini/ui/assets/cue/run.png +0 -0
- gini/ui/assets/cue/serverless.png +0 -0
- gini/ui/assets/cue/settings.png +0 -0
- gini/ui/assets/cue/welcome.png +0 -0
- gini/ui/assets/cue/wizard.png +0 -0
- gini/ui/assistant.py +2111 -0
- gini/ui/author_dialog.py +184 -0
- gini/ui/board_dialog.py +247 -0
- gini/ui/branding.py +21 -0
- gini/ui/canvas.py +2007 -0
- gini/ui/chat_panel.py +7 -0
- gini/ui/cpu_journey.py +212 -0
- gini/ui/cpu_lab.py +306 -0
- gini/ui/cue_cards.py +214 -0
- gini/ui/dashboard.py +222 -0
- gini/ui/diagnose_game.py +336 -0
- gini/ui/fingerprint_lab.py +219 -0
- gini/ui/flash_dialog.py +244 -0
- gini/ui/flow_layout.py +63 -0
- gini/ui/fragment_manager.py +1415 -0
- gini/ui/game_catalog.py +184 -0
- gini/ui/game_renderers.py +340 -0
- gini/ui/games_lab.py +90 -0
- gini/ui/inspector.py +1055 -0
- gini/ui/live_metrics.py +130 -0
- gini/ui/machine_lab.py +1412 -0
- gini/ui/main_window.py +3153 -0
- gini/ui/memory_lab.py +371 -0
- gini/ui/mission_panel.py +302 -0
- gini/ui/mode_indicator.py +227 -0
- gini/ui/palette.py +112 -0
- gini/ui/peripherals.py +218 -0
- gini/ui/process_tree.py +130 -0
- gini/ui/reset_dialog.py +179 -0
- gini/ui/router_lab.py +776 -0
- gini/ui/run_button.py +183 -0
- gini/ui/settings_dialog.py +234 -0
- gini/ui/signin_dialog.py +111 -0
- gini/ui/storage_lab.py +219 -0
- gini/ui/syscall_builder.py +235 -0
- gini/ui/syscall_lab.py +152 -0
- gini/ui/theme/__init__.py +5 -0
- gini/ui/theme/icons.py +145 -0
- gini/ui/theme/manager.py +291 -0
- gini/ui/theme/tokens.py +194 -0
- gini/ui/trap_lab.py +270 -0
- gini/ui/worker_host.py +102 -0
- gini/ui/zoo_lab.py +112 -0
- gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
- gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
- gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
- gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
- gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Project persistence — save/load a topology as a JSON project.
|
|
2
|
+
|
|
3
|
+
Uses the domain model's own to_dict/from_dict, so the on-disk format is just the
|
|
4
|
+
topology (devices + links + positions + properties) wrapped with a version. A legacy
|
|
5
|
+
.gsav reader can be added later; this is the clean native format.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ..domain.topology import Topology
|
|
13
|
+
|
|
14
|
+
PROJECT_EXT = ".gini"
|
|
15
|
+
FORMAT = "gini-project"
|
|
16
|
+
VERSION = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def save_project(topo: Topology, path: str | Path) -> None:
|
|
20
|
+
data = {"format": FORMAT, "version": VERSION, "topology": topo.to_dict()}
|
|
21
|
+
Path(path).write_text(json.dumps(data, indent=2))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_project(path: str | Path) -> Topology:
|
|
25
|
+
data = json.loads(Path(path).read_text())
|
|
26
|
+
if data.get("format") != FORMAT:
|
|
27
|
+
raise ValueError(f"not a GINI project file: {path}")
|
|
28
|
+
return Topology.from_dict(data["topology"])
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Docker-backed probe Runner — GINI's runtime as the behavioral oracle (Mac-side).
|
|
2
|
+
|
|
3
|
+
Implements the `domain.probes.Runner` seam by exec'ing checks inside the running topology's
|
|
4
|
+
containers via `docker compose exec` (the same compose project the orchestrator launched). This
|
|
5
|
+
runs only where Docker + a live stack exist (the student's Mac), so it is written defensively:
|
|
6
|
+
`available()` returns False whenever the stack isn't up, which keeps behavioral objectives
|
|
7
|
+
`pending` rather than wrongly failed. Unit-tested logic lives with `domain.probes` (FakeRunner);
|
|
8
|
+
this adapter is exercised in real Docker on the Mac.
|
|
9
|
+
|
|
10
|
+
Service-name resolution: the compiler names each compose service after the device name
|
|
11
|
+
(lowercased), so a probe's `src`/`dst` map to services; cloud-plane services reach each other by
|
|
12
|
+
name over the shared network. Networking-plane addressing (by IP) is a Phase-2.x refinement.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import subprocess
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DockerProbeRunner:
|
|
20
|
+
def __init__(self, orchestrator, *, timeout: float = 8.0) -> None:
|
|
21
|
+
self.orch = orchestrator # services.orchestrator.Orchestrator (has _dc, status)
|
|
22
|
+
self.timeout = timeout
|
|
23
|
+
self._svc_cache: dict | None = None
|
|
24
|
+
|
|
25
|
+
# -- availability ------------------------------------------------------- #
|
|
26
|
+
def available(self) -> bool:
|
|
27
|
+
try:
|
|
28
|
+
states = self.orch.status()
|
|
29
|
+
except Exception:
|
|
30
|
+
return False
|
|
31
|
+
return any(str(s).startswith("running") for s in (states or {}).values())
|
|
32
|
+
|
|
33
|
+
def _service(self, name: str) -> str:
|
|
34
|
+
"""Map a device name to its compose service. The orchestrator's status() keys ARE the
|
|
35
|
+
service names; match case-insensitively, else fall back to the lowercased name."""
|
|
36
|
+
if self._svc_cache is None:
|
|
37
|
+
try:
|
|
38
|
+
self._svc_cache = {k.lower(): k for k in (self.orch.status() or {})}
|
|
39
|
+
except Exception:
|
|
40
|
+
self._svc_cache = {}
|
|
41
|
+
return self._svc_cache.get(name.lower(), name.lower())
|
|
42
|
+
|
|
43
|
+
def _exec(self, service: str, argv: list[str]) -> tuple[int, str]:
|
|
44
|
+
cmd = [*self.orch._dc, "exec", "-T", service, *argv]
|
|
45
|
+
# `docker compose` (no -f) finds the project via the CWD — every orchestrator call runs in
|
|
46
|
+
# the workdir, so the probe MUST too, or `exec` can't find the running stack (a false
|
|
47
|
+
# negative: probes fail even though the containers are up and reachable).
|
|
48
|
+
wd = getattr(self.orch, "workdir", None)
|
|
49
|
+
try:
|
|
50
|
+
r = subprocess.run(cmd, cwd=str(wd) if wd else None,
|
|
51
|
+
capture_output=True, text=True, timeout=self.timeout)
|
|
52
|
+
return r.returncode, (r.stdout or "") + (r.stderr or "")
|
|
53
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
54
|
+
return 124, ""
|
|
55
|
+
|
|
56
|
+
# -- probes ------------------------------------------------------------- #
|
|
57
|
+
def reach(self, src: str, dst: str, port: int | None = None) -> bool:
|
|
58
|
+
s, d = self._service(src), self._service(dst)
|
|
59
|
+
if port is None: # L3 reachability via ICMP
|
|
60
|
+
code, _ = self._exec(s, ["ping", "-c", "1", "-W", "1", d])
|
|
61
|
+
return code == 0
|
|
62
|
+
# L4 reachability: a TCP connect using bash's /dev/tcp (no extra tools needed)
|
|
63
|
+
code, _ = self._exec(s, ["bash", "-lc", f"exec 3<>/dev/tcp/{d}/{port}"])
|
|
64
|
+
return code == 0
|
|
65
|
+
|
|
66
|
+
def http(self, src: str, dst: str, port: int) -> bool:
|
|
67
|
+
s, d = self._service(src), self._service(dst)
|
|
68
|
+
code, _ = self._exec(s, ["curl", "-fsS", "-m", "4", "-o", "/dev/null",
|
|
69
|
+
f"http://{d}:{port}/"])
|
|
70
|
+
return code == 0
|
|
71
|
+
|
|
72
|
+
def backends(self, lb: str) -> int:
|
|
73
|
+
"""How many backends the load balancer is REALLY serving from — i.e. upstreams it was
|
|
74
|
+
configured with that are actually accepting connections right now. A config listing two
|
|
75
|
+
servers proves nothing; this proves the fan-out has somewhere to go.
|
|
76
|
+
|
|
77
|
+
The LB compiles to nginx, so read its upstream block, then probe each target from inside the
|
|
78
|
+
LB container itself (that's the path traffic would take)."""
|
|
79
|
+
import re
|
|
80
|
+
svc = self._service(lb)
|
|
81
|
+
code, out = self._exec(svc, ["sh", "-c",
|
|
82
|
+
"cat /etc/nginx/conf.d/*.conf /etc/nginx/nginx.conf 2>/dev/null"])
|
|
83
|
+
if code != 0 or not out:
|
|
84
|
+
return 0
|
|
85
|
+
targets = re.findall(r"^\s*server\s+([A-Za-z0-9._-]+):(\d+)\s*;", out, re.M)
|
|
86
|
+
live = 0
|
|
87
|
+
for host, port in targets:
|
|
88
|
+
# try a few tools — the image is alpine-ish and may have any of them
|
|
89
|
+
probe = (f"nc -z -w2 {host} {port} 2>/dev/null || "
|
|
90
|
+
f"wget -q -T2 -O /dev/null http://{host}:{port}/ 2>/dev/null || "
|
|
91
|
+
f"curl -fsS -m2 -o /dev/null http://{host}:{port}/ 2>/dev/null")
|
|
92
|
+
c, _ = self._exec(svc, ["sh", "-c", probe])
|
|
93
|
+
if c == 0:
|
|
94
|
+
live += 1
|
|
95
|
+
return live
|
|
96
|
+
|
|
97
|
+
def flow(self, ovs: str, match: str = "") -> bool:
|
|
98
|
+
"""Did the controller ACTUALLY install flows on this switch? Reads the live OpenFlow table
|
|
99
|
+
from the gRouter (the OVS runs in --openflow mode) over its control socket — the exact same
|
|
100
|
+
path the Router Lab's flow view uses — and reuses the shipped parser.
|
|
101
|
+
|
|
102
|
+
`match` empty (or 'any') = "any flow at all is installed", which is the honest test that the
|
|
103
|
+
control plane did something. A non-empty match is a substring test against the entries."""
|
|
104
|
+
from ..domain.flowtable import flows
|
|
105
|
+
svc = self._service(ovs)
|
|
106
|
+
code, out = self._exec(svc, ["python3", "/build/grouter-build/grconsole.py",
|
|
107
|
+
f"/run/{svc}.ctl", "--once", "openflow entry all"])
|
|
108
|
+
if code != 0 or not out:
|
|
109
|
+
return False
|
|
110
|
+
try:
|
|
111
|
+
rows = flows(out)
|
|
112
|
+
except Exception: # noqa: BLE001 — a malformed dump is just "no proof"
|
|
113
|
+
return False
|
|
114
|
+
if not rows:
|
|
115
|
+
return False
|
|
116
|
+
if not match or match.lower() in ("any", "*"):
|
|
117
|
+
return True
|
|
118
|
+
needle = match.lower()
|
|
119
|
+
return any(needle in str(r).lower() for r in rows)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class RuntimeRunner(DockerProbeRunner):
|
|
123
|
+
"""DockerProbeRunner + `measure()` — the runtime half of an output check. It resolves
|
|
124
|
+
`measure(rider_type, metric)` by reading the LIVE measurement of the matching attached Source/Sink
|
|
125
|
+
from `get_results` (the streaming rider snapshots the grader has already started + let run). We
|
|
126
|
+
read the streaming session's reading rather than doing a one-shot capture, because `docker exec`
|
|
127
|
+
block-buffers output — a timeout-killed one-shot comes back empty even when the manual streaming
|
|
128
|
+
view sees packets. `get_results()` → {rider_id: snapshot}; `get_topology()` finds the rider."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, orchestrator, get_topology, get_results, *, timeout: float = 8.0) -> None:
|
|
131
|
+
super().__init__(orchestrator, timeout=timeout)
|
|
132
|
+
self._get_topology = get_topology
|
|
133
|
+
self._get_results = get_results
|
|
134
|
+
|
|
135
|
+
def measure(self, rider_type: str, metric: str):
|
|
136
|
+
from ..domain.objectives import slot_match
|
|
137
|
+
base, _, slot = str(rider_type).partition("@") # measure(packet_view@A, packets)
|
|
138
|
+
topo = self._get_topology()
|
|
139
|
+
results = self._get_results() or {}
|
|
140
|
+
for d in getattr(topo, "devices", {}).values():
|
|
141
|
+
if getattr(d, "type_key", None) != base:
|
|
142
|
+
continue
|
|
143
|
+
if not slot_match(getattr(d, "slot", ""), slot):
|
|
144
|
+
continue
|
|
145
|
+
snap = results.get(d.id)
|
|
146
|
+
m = (snap or {}).get("measurement") or {}
|
|
147
|
+
if metric in m:
|
|
148
|
+
return m.get(metric)
|
|
149
|
+
return None
|
gini/services/project.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Project = a folder holding a FAMILY of related experiments.
|
|
2
|
+
|
|
3
|
+
A project is the unit of *context*: several experiments that belong together (build a LAN, route
|
|
4
|
+
between two LANs, convert it to SDN) share one teacher's **brief** and one **Ask GINI conversation**,
|
|
5
|
+
so the tutor remembers the whole arc instead of forgetting everything each time you move on. An
|
|
6
|
+
*experiment* is one topology inside that project. Layout:
|
|
7
|
+
|
|
8
|
+
MyProject/
|
|
9
|
+
project.json {name, brief, created, modified, current}
|
|
10
|
+
ai.json {messages, history} ← the shared Ask GINI transcript
|
|
11
|
+
experiments/
|
|
12
|
+
Basic LAN.gini a topology (native .gini JSON — see persistence.py)
|
|
13
|
+
Routed LAN.gini
|
|
14
|
+
SDN.gini
|
|
15
|
+
|
|
16
|
+
Two older shapes still open, and are migrated in place on first load:
|
|
17
|
+
* the single-experiment project folder (`topology.gini` at the root) — moved into
|
|
18
|
+
`experiments/` under the project's own name;
|
|
19
|
+
* a bare single-file `.gini` — still loads as a topology via persistence.py.
|
|
20
|
+
|
|
21
|
+
Keeping experiments as separate files means each topology stays clean and shareable on its own,
|
|
22
|
+
while the brief and the AI conversation live once, at the project level.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
import time
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from ..domain.topology import Topology
|
|
32
|
+
from .persistence import load_project as _load_topology
|
|
33
|
+
from .persistence import save_project as _save_topology
|
|
34
|
+
|
|
35
|
+
TOPOLOGY_FILE = "topology.gini" # legacy single-experiment layout
|
|
36
|
+
META_FILE = "project.json"
|
|
37
|
+
AI_FILE = "ai.json"
|
|
38
|
+
EXPERIMENTS_DIR = "experiments"
|
|
39
|
+
EXT = ".gini"
|
|
40
|
+
META_FORMAT = "gini-project-dir"
|
|
41
|
+
META_VERSION = 2 # v1 = one topology.gini at the root
|
|
42
|
+
# A project's first experiment gets a neutral, numbered name — NEVER the project's own. Naming it
|
|
43
|
+
# after the project made the experiment list read as though the project were an item inside itself.
|
|
44
|
+
# The -01 suffix matches the Project-01 house style and invites Experiment-02, -03 alongside it.
|
|
45
|
+
FIRST_EXPERIMENT = "Experiment-01"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read_json(path: Path) -> dict:
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(Path(path).read_text())
|
|
51
|
+
return data if isinstance(data, dict) else {}
|
|
52
|
+
except Exception: # missing or malformed -> empty
|
|
53
|
+
return {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def safe_name(name: str) -> str:
|
|
57
|
+
"""An experiment name that is safe as a filename but still human-readable on disk."""
|
|
58
|
+
s = re.sub(r'[/\\:*?"<>|]+', "-", (name or "").strip()).strip(". ")
|
|
59
|
+
return s or "Untitled"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_project_dir(path) -> bool:
|
|
63
|
+
"""True for a v2 project (experiments/) or a v1 one (topology.gini at the root)."""
|
|
64
|
+
p = Path(path)
|
|
65
|
+
if not p.is_dir():
|
|
66
|
+
return False
|
|
67
|
+
return (p / TOPOLOGY_FILE).exists() or (p / EXPERIMENTS_DIR).is_dir()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def experiments_dir(path) -> Path:
|
|
71
|
+
return Path(path) / EXPERIMENTS_DIR
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# -- experiments ------------------------------------------------------------- #
|
|
75
|
+
def list_experiments(path) -> list[dict]:
|
|
76
|
+
"""Every experiment in the project, most-recently-modified first."""
|
|
77
|
+
d = experiments_dir(path)
|
|
78
|
+
out: list[dict] = []
|
|
79
|
+
if d.is_dir():
|
|
80
|
+
for f in d.glob(f"*{EXT}"):
|
|
81
|
+
out.append({"name": f.stem, "path": str(f), "modified": f.stat().st_mtime})
|
|
82
|
+
out.sort(key=lambda x: x["modified"], reverse=True)
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def experiment_path(path, name: str) -> Path:
|
|
87
|
+
return experiments_dir(path) / f"{safe_name(name)}{EXT}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def save_experiment(path, topology: Topology, name: str) -> str:
|
|
91
|
+
"""Write one experiment's topology into the project. Returns the file path."""
|
|
92
|
+
d = experiments_dir(path)
|
|
93
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
fp = experiment_path(path, name)
|
|
95
|
+
_save_topology(topology, fp)
|
|
96
|
+
return str(fp)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_experiment(path, name: str) -> Topology:
|
|
100
|
+
return _load_topology(experiment_path(path, name))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def delete_experiment(path, name: str) -> bool:
|
|
104
|
+
fp = experiment_path(path, name)
|
|
105
|
+
if not fp.exists():
|
|
106
|
+
return False
|
|
107
|
+
fp.unlink()
|
|
108
|
+
meta = _read_json(Path(path) / META_FILE)
|
|
109
|
+
if meta.get("current") == safe_name(name): # don't point at a file that's gone
|
|
110
|
+
meta["current"] = ""
|
|
111
|
+
(Path(path) / META_FILE).write_text(json.dumps(meta, indent=2))
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def rename_experiment(path, old: str, new: str) -> bool:
|
|
116
|
+
src, dst = experiment_path(path, old), experiment_path(path, new)
|
|
117
|
+
if not src.exists() or dst.exists():
|
|
118
|
+
return False
|
|
119
|
+
src.rename(dst)
|
|
120
|
+
meta = _read_json(Path(path) / META_FILE)
|
|
121
|
+
if meta.get("current") == safe_name(old):
|
|
122
|
+
meta["current"] = safe_name(new)
|
|
123
|
+
(Path(path) / META_FILE).write_text(json.dumps(meta, indent=2))
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# -- migration --------------------------------------------------------------- #
|
|
128
|
+
def migrate_project(path) -> bool:
|
|
129
|
+
"""v1 → v2 in place: move a root `topology.gini` into `experiments/<project name>.gini`.
|
|
130
|
+
Returns True if anything moved. Safe to call on an already-migrated project."""
|
|
131
|
+
p = Path(path)
|
|
132
|
+
legacy = p / TOPOLOGY_FILE
|
|
133
|
+
if not legacy.exists():
|
|
134
|
+
return False
|
|
135
|
+
meta = _read_json(p / META_FILE)
|
|
136
|
+
name = FIRST_EXPERIMENT
|
|
137
|
+
d = experiments_dir(p)
|
|
138
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
target = d / f"{name}{EXT}"
|
|
140
|
+
if not target.exists():
|
|
141
|
+
legacy.rename(target)
|
|
142
|
+
else:
|
|
143
|
+
legacy.unlink() # already migrated; drop the stale copy
|
|
144
|
+
meta.update({"format": META_FORMAT, "version": META_VERSION, "current": name,
|
|
145
|
+
"name": meta.get("name") or p.name})
|
|
146
|
+
(p / META_FILE).write_text(json.dumps(meta, indent=2))
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# -- project ----------------------------------------------------------------- #
|
|
151
|
+
def save_project_dir(path, topology: Topology, *, name: str | None = None, brief: str = "",
|
|
152
|
+
ai_state: dict | None = None, experiment: str | None = None) -> str:
|
|
153
|
+
"""Write the project: the CURRENT experiment's topology plus the project-level brief and AI
|
|
154
|
+
conversation. `experiment` names the topology being saved (defaults to the project name, which
|
|
155
|
+
is what a freshly-created project uses). Returns the folder path."""
|
|
156
|
+
p = Path(path)
|
|
157
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
prev = _read_json(p / META_FILE)
|
|
159
|
+
pname = name or topology.name or p.name
|
|
160
|
+
exp = safe_name(experiment or prev.get("current") or FIRST_EXPERIMENT)
|
|
161
|
+
save_experiment(p, topology, exp)
|
|
162
|
+
now = time.time()
|
|
163
|
+
meta = {
|
|
164
|
+
"format": META_FORMAT, "version": META_VERSION,
|
|
165
|
+
"name": pname,
|
|
166
|
+
"brief": brief or "",
|
|
167
|
+
"current": exp,
|
|
168
|
+
"created": prev.get("created", now),
|
|
169
|
+
"modified": now,
|
|
170
|
+
}
|
|
171
|
+
(p / META_FILE).write_text(json.dumps(meta, indent=2))
|
|
172
|
+
(p / AI_FILE).write_text(json.dumps(ai_state or {}, indent=2))
|
|
173
|
+
return str(p)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def load_project_dir(path) -> dict:
|
|
177
|
+
"""Return {topology, name, brief, ai_state, path, experiment, experiments} for a project.
|
|
178
|
+
|
|
179
|
+
`topology` is the *current* experiment. A v1 project is migrated first, so old projects open
|
|
180
|
+
unchanged from the user's point of view."""
|
|
181
|
+
p = Path(path)
|
|
182
|
+
migrate_project(p)
|
|
183
|
+
meta = _read_json(p / META_FILE)
|
|
184
|
+
pname = meta.get("name") or p.name
|
|
185
|
+
exps = list_experiments(p)
|
|
186
|
+
cur = meta.get("current") or ""
|
|
187
|
+
known = {e["name"] for e in exps}
|
|
188
|
+
if cur not in known: # missing/renamed -> newest, else a fresh one
|
|
189
|
+
cur = exps[0]["name"] if exps else FIRST_EXPERIMENT
|
|
190
|
+
try:
|
|
191
|
+
topo = load_experiment(p, cur)
|
|
192
|
+
except Exception: # noqa: BLE001 — an empty/new project
|
|
193
|
+
topo = Topology(cur)
|
|
194
|
+
return {
|
|
195
|
+
"topology": topo,
|
|
196
|
+
"name": pname,
|
|
197
|
+
"brief": meta.get("brief", ""),
|
|
198
|
+
"ai_state": _read_json(p / AI_FILE),
|
|
199
|
+
"path": str(p),
|
|
200
|
+
"experiment": cur,
|
|
201
|
+
"experiments": [e["name"] for e in exps] or [cur],
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def list_projects(projects_root) -> list[dict]:
|
|
206
|
+
"""All project folders under `projects_root`, most-recently-modified first."""
|
|
207
|
+
root = Path(projects_root)
|
|
208
|
+
out: list[dict] = []
|
|
209
|
+
if root.is_dir():
|
|
210
|
+
for d in root.iterdir():
|
|
211
|
+
if is_project_dir(d):
|
|
212
|
+
meta = _read_json(d / META_FILE)
|
|
213
|
+
out.append({"name": meta.get("name", d.name), "path": str(d),
|
|
214
|
+
"modified": meta.get("modified", 0),
|
|
215
|
+
"experiments": len(list_experiments(d))})
|
|
216
|
+
out.sort(key=lambda x: x["modified"], reverse=True)
|
|
217
|
+
return out
|
gini/services/remote.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""gBuilder's client for a remote GINI server (the Phase-3 transport).
|
|
2
|
+
|
|
3
|
+
The GUI builds a topology and, in remote mode, hands it to this client, which sends it to
|
|
4
|
+
the brokered GINI server over an authenticated HTTP API. The server compiles + policy-checks
|
|
5
|
+
+ runs it on the Kata host; metrics/startup come back the same way. Same shape as the local
|
|
6
|
+
orchestrator path, so the canvas/compiler don't care which backend is active.
|
|
7
|
+
|
|
8
|
+
`transport` is injectable — production uses the urllib HTTP transport below; tests pass a
|
|
9
|
+
shim that calls `GiniServer.handle` directly, exercising the whole protocol without sockets.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RemoteClient:
|
|
19
|
+
def __init__(self, base_url: str = "", transport=None, token: str | None = None) -> None:
|
|
20
|
+
self.base_url = (base_url or "").rstrip("/")
|
|
21
|
+
self.token = token
|
|
22
|
+
self._transport = transport or self._http
|
|
23
|
+
|
|
24
|
+
# -- transport ---------------------------------------------------------- #
|
|
25
|
+
def _http(self, method: str, path: str, token: str | None, body: dict) -> tuple[int, dict]:
|
|
26
|
+
data = json.dumps(body or {}).encode() if method == "POST" else None
|
|
27
|
+
req = urllib.request.Request(self.base_url + path, data=data, method=method)
|
|
28
|
+
req.add_header("Content-Type", "application/json")
|
|
29
|
+
if token:
|
|
30
|
+
req.add_header("Authorization", "Bearer " + token)
|
|
31
|
+
try:
|
|
32
|
+
r = urllib.request.urlopen(req, timeout=30)
|
|
33
|
+
return r.getcode(), json.loads(r.read().decode() or "{}")
|
|
34
|
+
except urllib.error.HTTPError as e:
|
|
35
|
+
try:
|
|
36
|
+
return e.code, json.loads(e.read().decode() or "{}")
|
|
37
|
+
except Exception: # noqa: BLE001
|
|
38
|
+
return e.code, {"error": f"HTTP {e.code}"}
|
|
39
|
+
except Exception as e: # noqa: BLE001 — connection refused, DNS, …
|
|
40
|
+
return 0, {"error": str(e)}
|
|
41
|
+
|
|
42
|
+
def _call(self, method: str, path: str, body: dict | None = None) -> tuple[int, dict]:
|
|
43
|
+
return self._transport(method, path, self.token, body or {})
|
|
44
|
+
|
|
45
|
+
# -- API ---------------------------------------------------------------- #
|
|
46
|
+
def login(self, username: str, password: str) -> tuple[bool, str]:
|
|
47
|
+
st, body = self._transport("POST", "/login", None,
|
|
48
|
+
{"username": username, "password": password})
|
|
49
|
+
if st == 200 and body.get("token"):
|
|
50
|
+
self.token = body["token"]
|
|
51
|
+
return True, ""
|
|
52
|
+
return False, body.get("error", "login failed")
|
|
53
|
+
|
|
54
|
+
def run(self, topology) -> tuple[bool, str]:
|
|
55
|
+
"""Start the lab on the server (returns once accepted — the launch runs async there,
|
|
56
|
+
because image pulls/builds can take minutes). Poll `wait_until_running` for the result."""
|
|
57
|
+
st, body = self._call("POST", "/run", {"topology": topology.to_dict()})
|
|
58
|
+
if st in (200, 202) and body.get("ok"):
|
|
59
|
+
return True, body.get("state", "starting")
|
|
60
|
+
return False, body.get("error", "run failed")
|
|
61
|
+
|
|
62
|
+
def run_state(self) -> dict:
|
|
63
|
+
"""{'state': starting|running|error|stopped, 'message': ...}."""
|
|
64
|
+
return self._call("GET", "/status")[1].get("run", {})
|
|
65
|
+
|
|
66
|
+
def wait_until_running(self, timeout: float = 240, interval: float = 2.0) -> tuple[bool, str]:
|
|
67
|
+
"""Poll until the server's launch finishes (or fails / times out)."""
|
|
68
|
+
import time
|
|
69
|
+
deadline = time.time() + timeout
|
|
70
|
+
while time.time() < deadline:
|
|
71
|
+
rs = self.run_state()
|
|
72
|
+
if rs.get("state") == "running":
|
|
73
|
+
return True, rs.get("message", "")
|
|
74
|
+
if rs.get("state") == "error":
|
|
75
|
+
return False, rs.get("message", "run failed")
|
|
76
|
+
time.sleep(interval)
|
|
77
|
+
return False, "timed out waiting for the lab to come up"
|
|
78
|
+
|
|
79
|
+
def stop(self) -> tuple[bool, str]:
|
|
80
|
+
st, body = self._call("POST", "/stop")
|
|
81
|
+
return st == 200, body.get("message") or body.get("error", "")
|
|
82
|
+
|
|
83
|
+
def status(self) -> dict:
|
|
84
|
+
return self._call("GET", "/status")[1].get("status", {})
|
|
85
|
+
|
|
86
|
+
def metrics(self) -> dict:
|
|
87
|
+
return self._call("GET", "/metrics")[1] # {"stats": {...}, "startup": {...}}
|
|
88
|
+
|
|
89
|
+
def capabilities(self) -> dict:
|
|
90
|
+
return self._call("GET", "/capabilities")[1] # {"kata": bool}
|
|
91
|
+
|
|
92
|
+
def kata_available(self) -> bool:
|
|
93
|
+
return bool(self.capabilities().get("kata"))
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Docker-backed rider execution (Mac-side).
|
|
2
|
+
|
|
3
|
+
Runs a Source/Sink INSIDE its donor container via `docker compose exec` — the same seam
|
|
4
|
+
`probe_runner` uses — then reduces the raw output to a measurement with `domain.riders`. A rider has
|
|
5
|
+
no container of its own, so there is nothing to launch: we exec its tool in the donor the student
|
|
6
|
+
attached it to. Written defensively: `available()` is False when no stack is up, and a missing donor
|
|
7
|
+
or a broken command returns an error dict rather than raising, so the UI can show it inline.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import subprocess
|
|
12
|
+
|
|
13
|
+
from ..domain import riders as _riders
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RiderRunner:
|
|
17
|
+
def __init__(self, orchestrator, *, timeout: float = 15.0) -> None:
|
|
18
|
+
self.orch = orchestrator # services.orchestrator.Orchestrator (has _dc, status, workdir)
|
|
19
|
+
self.timeout = timeout
|
|
20
|
+
self._svc_cache: dict | None = None
|
|
21
|
+
|
|
22
|
+
def available(self) -> bool:
|
|
23
|
+
try:
|
|
24
|
+
states = self.orch.status()
|
|
25
|
+
except Exception:
|
|
26
|
+
return False
|
|
27
|
+
return any(str(s).startswith("running") for s in (states or {}).values())
|
|
28
|
+
|
|
29
|
+
def _service(self, name: str) -> str:
|
|
30
|
+
if self._svc_cache is None:
|
|
31
|
+
try:
|
|
32
|
+
self._svc_cache = {k.lower(): k for k in (self.orch.status() or {})}
|
|
33
|
+
except Exception:
|
|
34
|
+
self._svc_cache = {}
|
|
35
|
+
return self._svc_cache.get(name.lower(), name.lower())
|
|
36
|
+
|
|
37
|
+
def _exec(self, service: str, argv: list[str]) -> tuple[int, str]:
|
|
38
|
+
cmd = [*self.orch._dc, "exec", "-T", service, *argv]
|
|
39
|
+
wd = getattr(self.orch, "workdir", None)
|
|
40
|
+
try:
|
|
41
|
+
r = subprocess.run(cmd, cwd=str(wd) if wd else None,
|
|
42
|
+
capture_output=True, text=True, timeout=self.timeout)
|
|
43
|
+
return r.returncode, (r.stdout or "") + (r.stderr or "")
|
|
44
|
+
except subprocess.TimeoutExpired as e: # keep partial output, flag the timeout
|
|
45
|
+
partial = e.stdout if isinstance(e.stdout, str) else ""
|
|
46
|
+
return (0 if partial else 124), partial
|
|
47
|
+
except (FileNotFoundError, OSError):
|
|
48
|
+
return 124, ""
|
|
49
|
+
|
|
50
|
+
# pingable/curlable donor neighbours we can auto-target (reachable by service name)
|
|
51
|
+
_TARGETABLE = {"host", "router", "instance", "container", "kinstance", "web_app", "firewall"}
|
|
52
|
+
|
|
53
|
+
def _infer_target(self, topo, donor_id: str, rider_id: str) -> str:
|
|
54
|
+
"""When a ping/http rider has no Target, pick a reachable name: a directly-wired neighbour of
|
|
55
|
+
the donor first (excluding riders), else any other targetable node on the board."""
|
|
56
|
+
donor = topo.devices.get(donor_id)
|
|
57
|
+
for nb in topo.neighbors(donor_id): # prefer a direct network neighbour
|
|
58
|
+
if nb.id != rider_id and nb.type_key in self._TARGETABLE:
|
|
59
|
+
return nb.name
|
|
60
|
+
for d in topo.devices.values(): # else anything else pingable by name
|
|
61
|
+
if d.id not in (donor_id, rider_id) and d.type_key in self._TARGETABLE:
|
|
62
|
+
return d.name
|
|
63
|
+
return ""
|
|
64
|
+
|
|
65
|
+
def run(self, topo, rider_id: str) -> dict:
|
|
66
|
+
"""Execute the rider on its donor and return
|
|
67
|
+
{ok, raw, measurement, summary, donor} — or {ok: False, error} on any problem."""
|
|
68
|
+
rider = topo.devices.get(rider_id)
|
|
69
|
+
if rider is None:
|
|
70
|
+
return {"ok": False, "error": "no such rider"}
|
|
71
|
+
donor = topo.donor_of(rider_id)
|
|
72
|
+
if donor is None:
|
|
73
|
+
return {"ok": False, "error": f"{rider.name} isn't attached to a donor yet."}
|
|
74
|
+
|
|
75
|
+
props = dict(rider.properties)
|
|
76
|
+
inferred = ""
|
|
77
|
+
if rider.type_key in ("ping_probe", "http_probe") and not (props.get("Target") or "").strip():
|
|
78
|
+
inferred = self._infer_target(topo, donor.id, rider_id)
|
|
79
|
+
if inferred:
|
|
80
|
+
props["Target"] = inferred
|
|
81
|
+
try:
|
|
82
|
+
argv = _riders.build_command(rider.type_key, props)
|
|
83
|
+
except _riders.RiderError as e:
|
|
84
|
+
return {"ok": False, "error": str(e)}
|
|
85
|
+
service = self._service(donor.name)
|
|
86
|
+
code, raw = self._exec(service, argv)
|
|
87
|
+
if not (raw or "").strip():
|
|
88
|
+
# never show a blank "no output" — say what ran and how it exited, so the cause is
|
|
89
|
+
# visible (missing tool, unresolved target, timeout, wrong donor service, …)
|
|
90
|
+
hint = "timed out" if code == 124 else f"exit {code}"
|
|
91
|
+
raw = (f"(no output — {hint})\n"
|
|
92
|
+
f"$ docker compose exec {service} {' '.join(argv)}")
|
|
93
|
+
m = _riders.parse_measurement(rider.type_key, raw)
|
|
94
|
+
return {"ok": True, "code": code, "raw": raw, "measurement": m,
|
|
95
|
+
"summary": _riders.summarize(rider.type_key, m), "donor": donor.name,
|
|
96
|
+
"target": props.get("Target", ""), "inferred_target": inferred}
|