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
gini/domain/riders.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Riders — how a Source/Sink runs on its donor, and what its output *means*.
|
|
2
|
+
|
|
3
|
+
A rider has no container: it runs a tool INSIDE its donor (a Machine, Router, …). This module is the
|
|
4
|
+
pure, testable half of that — it turns a rider + its properties into a command to exec on the donor
|
|
5
|
+
(`build_command`) and turns the tool's raw output into a single gradable measurement
|
|
6
|
+
(`parse_measurement`). No Docker here: the thin adapter that actually execs lives in
|
|
7
|
+
`services.rider_runner`, exactly like `domain.probes` ↔ `services.probe_runner`.
|
|
8
|
+
|
|
9
|
+
The measurement is the "output-as-measurement" from the I/O design: one scalar (loss %, ok %, packet
|
|
10
|
+
count) that grading asserts on and that a composition edge can branch on. The raw text is the
|
|
11
|
+
"output-as-data" shown in the console.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
from . import devices as _devices
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RiderError(ValueError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _int(v, default: int) -> int:
|
|
25
|
+
try:
|
|
26
|
+
return max(0, int(str(v).strip()))
|
|
27
|
+
except (TypeError, ValueError):
|
|
28
|
+
return default
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_command(rider_type: str, props: dict | None = None) -> list[str]:
|
|
32
|
+
"""The argv to exec INSIDE the donor for this rider. Raises RiderError if a required field
|
|
33
|
+
(e.g. a ping/http Target) is missing, so the UI can prompt instead of running a broken command."""
|
|
34
|
+
props = props or {}
|
|
35
|
+
dt = _devices.REGISTRY.get(rider_type)
|
|
36
|
+
if dt is None or not getattr(dt, "rider", False):
|
|
37
|
+
raise RiderError(f"{rider_type} is not a rider")
|
|
38
|
+
|
|
39
|
+
# Count == 0 → run CONTINUOUSLY (until the student stops it); Count == N → do N and auto-stop.
|
|
40
|
+
if rider_type == "ping_probe":
|
|
41
|
+
target = (props.get("Target") or "").strip()
|
|
42
|
+
if not target:
|
|
43
|
+
raise RiderError("Ping Probe has no Target — set one in the inspector, or attach it to a "
|
|
44
|
+
"donor that's wired to something to auto-pick a reachable neighbour.")
|
|
45
|
+
n = _int(props.get("Count"), 0)
|
|
46
|
+
argv = ["ping"]
|
|
47
|
+
if n > 0:
|
|
48
|
+
argv += ["-c", str(n)]
|
|
49
|
+
argv += ["-W", "1", target]
|
|
50
|
+
return argv
|
|
51
|
+
|
|
52
|
+
if rider_type == "http_probe":
|
|
53
|
+
target = (props.get("Target") or "").strip()
|
|
54
|
+
if not target:
|
|
55
|
+
raise RiderError("HTTP Probe needs a Target (the host/service to request).")
|
|
56
|
+
path = (props.get("Path") or "/").strip() or "/"
|
|
57
|
+
if not path.startswith("/"):
|
|
58
|
+
path = "/" + path
|
|
59
|
+
n = _int(props.get("Count"), 0)
|
|
60
|
+
url = f"http://{target}{path}"
|
|
61
|
+
# one line per request: "<http_code> <time_total_seconds>"
|
|
62
|
+
one = (f'curl -sS -o /dev/null -m 5 -w "%{{http_code}} %{{time_total}}\\n" {url} '
|
|
63
|
+
f'|| echo "000 0"')
|
|
64
|
+
if n > 0:
|
|
65
|
+
script = f'for i in $(seq {n}); do {one}; done'
|
|
66
|
+
else:
|
|
67
|
+
script = f'while true; do {one}; sleep 0.5; done'
|
|
68
|
+
return ["sh", "-lc", script]
|
|
69
|
+
|
|
70
|
+
if rider_type == "packet_view":
|
|
71
|
+
n = _int(props.get("Count"), 0)
|
|
72
|
+
filt = (props.get("Filter") or "").strip()
|
|
73
|
+
# default to the GINI overlay (gini0), NOT `any` — otherwise students see Docker-bridge
|
|
74
|
+
# (eth0) plumbing mixed with their drawn network. Interface is overridable.
|
|
75
|
+
iface = (props.get("Interface") or "gini0").strip() or "gini0"
|
|
76
|
+
argv = ["tcpdump", "-n", "-l", "-i", iface] # -l = line-buffered, so it streams live
|
|
77
|
+
if n > 0:
|
|
78
|
+
argv += ["-c", str(n)]
|
|
79
|
+
if filt:
|
|
80
|
+
argv += filt.split()
|
|
81
|
+
return argv
|
|
82
|
+
|
|
83
|
+
if rider_type == "dns_probe":
|
|
84
|
+
name = (props.get("Target") or props.get("Query") or "").strip()
|
|
85
|
+
if not name:
|
|
86
|
+
raise RiderError("DNS Probe needs a Target — the hostname or service name to resolve.")
|
|
87
|
+
n = _int(props.get("Count"), 0)
|
|
88
|
+
# `getent hosts` uses the system resolver (files→DNS), so it honours the overlay entries
|
|
89
|
+
# GINI writes into /etc/hosts and returns the gini0 address — `dig` would bypass hosts and
|
|
90
|
+
# hit Docker's bridge DNS instead. Output line: "<HH:MM:SS> <ip-or-empty>".
|
|
91
|
+
lookup = "getent hosts %s 2>/dev/null | head -1 | awk '{print $1}'" % name
|
|
92
|
+
one = 'echo "$(date +%%T) $(%s)"' % lookup
|
|
93
|
+
script = ("for i in $(seq %d); do %s; sleep 1; done" % (n, one)) if n > 0 \
|
|
94
|
+
else ("while true; do %s; sleep 1; done" % one)
|
|
95
|
+
return ["sh", "-lc", script]
|
|
96
|
+
|
|
97
|
+
if rider_type == "traceroute_probe":
|
|
98
|
+
target = (props.get("Target") or "").strip()
|
|
99
|
+
if not target:
|
|
100
|
+
raise RiderError("Traceroute needs a Target to trace a path to.")
|
|
101
|
+
return ["traceroute", "-n", "-w", "2", target]
|
|
102
|
+
|
|
103
|
+
if rider_type == "iperf_client":
|
|
104
|
+
target = (props.get("Target") or "").strip()
|
|
105
|
+
if not target:
|
|
106
|
+
raise RiderError("iPerf Client needs a Target running an iPerf Server.")
|
|
107
|
+
secs = _int(props.get("Seconds"), 10) or 10
|
|
108
|
+
argv = ["iperf3", "-c", target, "-t", str(secs)]
|
|
109
|
+
# cap the rate: unlimited iperf saturates the user-space fabric and pins the Docker VM's CPU.
|
|
110
|
+
# A bounded rate is a controlled, repeatable measurement. "0"/"unlimited" opts out.
|
|
111
|
+
rate = (props.get("Bitrate") or "").strip()
|
|
112
|
+
if rate and rate.lower() not in ("0", "unlimited", "max"):
|
|
113
|
+
argv += ["-b", rate]
|
|
114
|
+
return argv
|
|
115
|
+
|
|
116
|
+
if rider_type == "iperf_server":
|
|
117
|
+
return ["iperf3", "-s"]
|
|
118
|
+
|
|
119
|
+
if rider_type == "iface_stats":
|
|
120
|
+
# stream rx/tx counters from /proc/net/dev, one snapshot per second
|
|
121
|
+
return ["sh", "-lc", 'while true; do cat /proc/net/dev; echo "==="; sleep 1; done']
|
|
122
|
+
|
|
123
|
+
if rider_type in ("xv6_shell", "xv6_workload"):
|
|
124
|
+
raise RiderError("xv6 riders run over the console, not docker — use xv6_command().")
|
|
125
|
+
|
|
126
|
+
raise RiderError(f"no command builder for rider {rider_type}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def xv6_command(rider_type: str, props: dict | None = None) -> str:
|
|
130
|
+
"""The line to TYPE into an xv6 Machine's console (the qemu-serial driver), for riders that ride
|
|
131
|
+
an xv6 donor. Raises RiderError if a required field is missing."""
|
|
132
|
+
props = props or {}
|
|
133
|
+
if rider_type == "xv6_shell":
|
|
134
|
+
cmd = (props.get("Command") or "").strip()
|
|
135
|
+
if not cmd:
|
|
136
|
+
raise RiderError("Shell Probe needs a Command to run on the xv6 Machine (e.g. 'ls').")
|
|
137
|
+
return cmd
|
|
138
|
+
if rider_type == "xv6_workload":
|
|
139
|
+
prog = (props.get("Program") or "spin").strip()
|
|
140
|
+
args = (props.get("Args") or "").strip()
|
|
141
|
+
line = prog + (f" {args}" if args else "")
|
|
142
|
+
if str(props.get("Background", "true")).lower() in ("true", "1", "yes"):
|
|
143
|
+
line += " &" # run in the background so several compete
|
|
144
|
+
return line
|
|
145
|
+
raise RiderError(f"{rider_type} is not an xv6 rider")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# --------------------------------------------------------------------------- #
|
|
149
|
+
# measurement parsing — raw tool output → one gradable scalar (+ a few extras)
|
|
150
|
+
# --------------------------------------------------------------------------- #
|
|
151
|
+
_PING_LOSS = re.compile(r"(\d+(?:\.\d+)?)%\s*packet loss")
|
|
152
|
+
_PING_RTT = re.compile(r"=\s*[\d.]+/([\d.]+)/[\d.]+") # min/AVG/max…
|
|
153
|
+
_PING_TXRX = re.compile(r"(\d+)\s+packets transmitted,\s*(\d+)\s+(?:packets )?received")
|
|
154
|
+
_TCPDUMP_CAP = re.compile(r"(\d+)\s+packets captured")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def parse_measurement(rider_type: str, raw: str) -> dict:
|
|
158
|
+
"""Reduce a rider's raw output to a measurement dict. Always returns a dict (never raises); an
|
|
159
|
+
unparseable output yields {'ok': False} so the caller can show 'no reading' rather than crash."""
|
|
160
|
+
raw = raw or ""
|
|
161
|
+
|
|
162
|
+
if rider_type == "ping_probe":
|
|
163
|
+
loss = _PING_LOSS.search(raw)
|
|
164
|
+
rtt = _PING_RTT.search(raw)
|
|
165
|
+
txrx = _PING_TXRX.search(raw)
|
|
166
|
+
times = [float(t) for t in re.findall(r"time=([\d.]+)", raw)] # per-reply RTTs
|
|
167
|
+
seqs = [int(s) for s in re.findall(r"(?:icmp_seq|seq)=(\d+)", raw)]
|
|
168
|
+
out: dict = {"ok": bool(loss or times)}
|
|
169
|
+
if loss: # finite run: trust the summary
|
|
170
|
+
out["loss_pct"] = float(loss.group(1))
|
|
171
|
+
elif seqs: # streaming: derive from per-packet lines
|
|
172
|
+
sent, recv = max(seqs) + 1, len(times)
|
|
173
|
+
out["loss_pct"] = round(max(0.0, (sent - recv) / sent * 100), 1) if sent else 0.0
|
|
174
|
+
if rtt:
|
|
175
|
+
out["rtt_avg_ms"] = float(rtt.group(1))
|
|
176
|
+
elif times:
|
|
177
|
+
out["rtt_avg_ms"] = round(sum(times) / len(times), 3)
|
|
178
|
+
if txrx:
|
|
179
|
+
out["transmitted"], out["received"] = int(txrx.group(1)), int(txrx.group(2))
|
|
180
|
+
elif seqs:
|
|
181
|
+
out["transmitted"], out["received"] = max(seqs) + 1, len(times)
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
if rider_type == "http_probe":
|
|
185
|
+
codes, times = [], []
|
|
186
|
+
for line in raw.splitlines():
|
|
187
|
+
parts = line.split()
|
|
188
|
+
if len(parts) == 2 and parts[0].isdigit():
|
|
189
|
+
codes.append(int(parts[0]))
|
|
190
|
+
try:
|
|
191
|
+
times.append(float(parts[1]))
|
|
192
|
+
except ValueError:
|
|
193
|
+
pass
|
|
194
|
+
if not codes:
|
|
195
|
+
return {"ok": False}
|
|
196
|
+
ok = sum(1 for c in codes if 200 <= c < 300)
|
|
197
|
+
out = {"ok": True, "requests": len(codes), "ok_count": ok,
|
|
198
|
+
"ok_pct": round(100.0 * ok / len(codes), 1)}
|
|
199
|
+
if times:
|
|
200
|
+
out["avg_ms"] = round(1000.0 * sum(times) / len(times), 1)
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
if rider_type == "packet_view":
|
|
204
|
+
low = raw.lower()
|
|
205
|
+
if "not found" in low or "permission denied" in low or "no such device" in low:
|
|
206
|
+
return {"ok": False, "error": raw.strip().splitlines()[-1] if raw.strip() else "failed"}
|
|
207
|
+
cap = _TCPDUMP_CAP.search(raw)
|
|
208
|
+
if cap:
|
|
209
|
+
return {"ok": True, "packets": int(cap.group(1))}
|
|
210
|
+
lines = [ln for ln in raw.splitlines() if re.match(r"^\d\d:\d\d:\d\d", ln)]
|
|
211
|
+
if lines:
|
|
212
|
+
return {"ok": True, "packets": len(lines)}
|
|
213
|
+
if "listening on" in low: # tcpdump ran fine, the link was just idle
|
|
214
|
+
return {"ok": True, "packets": 0}
|
|
215
|
+
return {"ok": False}
|
|
216
|
+
|
|
217
|
+
if rider_type == "dns_probe":
|
|
218
|
+
# each line: "<HH:MM:SS> <ip> <ip> ..." (empty tail = failed to resolve)
|
|
219
|
+
rows = [ln.split() for ln in raw.splitlines() if re.match(r"^\d\d:\d\d:\d\d", ln)]
|
|
220
|
+
if not rows:
|
|
221
|
+
return {"ok": False}
|
|
222
|
+
answered = sum(1 for r in rows if len(r) > 1)
|
|
223
|
+
last = next((" ".join(r[1:]) for r in reversed(rows) if len(r) > 1), "")
|
|
224
|
+
return {"ok": True, "queries": len(rows), "resolved": answered,
|
|
225
|
+
"resolved_pct": round(100.0 * answered / len(rows), 1), "answer": last}
|
|
226
|
+
|
|
227
|
+
if rider_type == "traceroute_probe":
|
|
228
|
+
hops = [ln for ln in raw.splitlines() if re.match(r"^\s*\d+\s", ln)]
|
|
229
|
+
if not hops:
|
|
230
|
+
return {"ok": False}
|
|
231
|
+
reached = not any("* * *" in h for h in hops[-1:])
|
|
232
|
+
return {"ok": True, "hops": len(hops), "reached": reached}
|
|
233
|
+
|
|
234
|
+
if rider_type in ("iperf_client", "iperf_server"):
|
|
235
|
+
# iperf3 prints "... 942 Mbits/sec ..." lines; take the last rate seen
|
|
236
|
+
rates = re.findall(r"([\d.]+)\s*([KMG])bits/sec", raw)
|
|
237
|
+
if "not found" in raw.lower() or "unable" in raw.lower() or "error" in raw.lower():
|
|
238
|
+
return {"ok": bool(rates), "error": raw.strip().splitlines()[-1] if raw.strip() else ""}
|
|
239
|
+
if not rates:
|
|
240
|
+
return {"ok": "iperf" in raw.lower() or "Server listening" in raw}
|
|
241
|
+
val, unit = rates[-1]
|
|
242
|
+
mbps = float(val) * {"K": 0.001, "M": 1.0, "G": 1000.0}[unit]
|
|
243
|
+
return {"ok": True, "mbps": round(mbps, 1)}
|
|
244
|
+
|
|
245
|
+
if rider_type == "iface_stats":
|
|
246
|
+
# sum non-loopback interface counters from the LAST /proc/net/dev block
|
|
247
|
+
blocks = raw.split("===")
|
|
248
|
+
block = next((b for b in reversed(blocks) if ":" in b), "")
|
|
249
|
+
rx_p = tx_p = 0
|
|
250
|
+
for ln in block.splitlines():
|
|
251
|
+
if ":" not in ln or ln.strip().startswith("Inter") or ln.strip().startswith("face"):
|
|
252
|
+
continue
|
|
253
|
+
name, _, rest = ln.partition(":")
|
|
254
|
+
nm = name.strip()
|
|
255
|
+
if nm == "lo" or nm.startswith("eth"): # hide loopback + the Docker-bridge plumbing
|
|
256
|
+
continue
|
|
257
|
+
f = rest.split()
|
|
258
|
+
if len(f) >= 16:
|
|
259
|
+
rx_p += int(f[1]); tx_p += int(f[9])
|
|
260
|
+
if not block.strip():
|
|
261
|
+
return {"ok": False}
|
|
262
|
+
return {"ok": True, "rx_packets": rx_p, "tx_packets": tx_p}
|
|
263
|
+
|
|
264
|
+
return {"ok": False}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# the gradable metrics each rider exposes (metric key, human label) — drives output-check authoring
|
|
268
|
+
METRICS: dict[str, list[tuple[str, str]]] = {
|
|
269
|
+
"ping_probe": [("loss_pct", "packet loss %"), ("rtt_avg_ms", "avg RTT (ms)")],
|
|
270
|
+
"http_probe": [("ok_pct", "2xx success %"), ("avg_ms", "avg latency (ms)")],
|
|
271
|
+
"dns_probe": [("resolved_pct", "resolved %")],
|
|
272
|
+
"traceroute_probe": [("hops", "hop count")],
|
|
273
|
+
"iperf_client": [("mbps", "throughput (Mbit/s)")],
|
|
274
|
+
"iperf_server": [("mbps", "throughput (Mbit/s)")],
|
|
275
|
+
"packet_view": [("packets", "packets captured")],
|
|
276
|
+
"iface_stats": [("rx_packets", "rx packets"), ("tx_packets", "tx packets")],
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def metrics_for(rider_type: str) -> list[tuple[str, str]]:
|
|
281
|
+
return METRICS.get(rider_type, [])
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def summarize(rider_type: str, m: dict) -> str:
|
|
285
|
+
"""A one-line, human summary of a measurement for the inspector chip."""
|
|
286
|
+
if not m or not m.get("ok"):
|
|
287
|
+
return "no reading"
|
|
288
|
+
if rider_type == "ping_probe":
|
|
289
|
+
s = f"{m.get('loss_pct', '?')}% loss"
|
|
290
|
+
if "rtt_avg_ms" in m:
|
|
291
|
+
s += f" · {m['rtt_avg_ms']} ms avg"
|
|
292
|
+
return s
|
|
293
|
+
if rider_type == "http_probe":
|
|
294
|
+
s = f"{m.get('ok_pct', '?')}% 2xx ({m.get('ok_count', 0)}/{m.get('requests', 0)})"
|
|
295
|
+
if "avg_ms" in m:
|
|
296
|
+
s += f" · {m['avg_ms']} ms avg"
|
|
297
|
+
return s
|
|
298
|
+
if rider_type == "packet_view":
|
|
299
|
+
return f"{m.get('packets', 0)} packets"
|
|
300
|
+
if rider_type == "dns_probe":
|
|
301
|
+
ans = m.get("answer", "")
|
|
302
|
+
return f"{m.get('resolved_pct', '?')}% resolved" + (f" → {ans}" if ans else "")
|
|
303
|
+
if rider_type == "traceroute_probe":
|
|
304
|
+
return f"{m.get('hops', 0)} hops" + ("" if m.get("reached", True) else " (unreached)")
|
|
305
|
+
if rider_type in ("iperf_client", "iperf_server"):
|
|
306
|
+
return f"{m.get('mbps', 0)} Mbit/s" if "mbps" in m else "listening"
|
|
307
|
+
if rider_type == "iface_stats":
|
|
308
|
+
return f"rx {m.get('rx_packets', 0)} · tx {m.get('tx_packets', 0)} pkts"
|
|
309
|
+
return "ok"
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Router Lab model — the gRouter's data-plane as a composable module graph.
|
|
2
|
+
|
|
3
|
+
Pure-Python and Qt-free so it's testable and (later) maps onto the real gRouter's
|
|
4
|
+
module graph over the control protocol. Today it drives the Router Lab editor and a
|
|
5
|
+
local packet trace for the step-through debugger.
|
|
6
|
+
|
|
7
|
+
Model (matches the consolidated gRouter design):
|
|
8
|
+
* a MODE gate at ingress: 'legacy' | 'openflow' (OpenFlow = flow table front door,
|
|
9
|
+
legacy pipeline becomes its NORMAL action),
|
|
10
|
+
* a fixed BASE pipeline (parse -> route -> rewrite),
|
|
11
|
+
* an ordered list of INLINE add-on / custom modules that compose in series.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ModuleType:
|
|
20
|
+
key: str
|
|
21
|
+
label: str
|
|
22
|
+
icon: str # an icon key from ui.theme.icons
|
|
23
|
+
accent: str # accent color key
|
|
24
|
+
kind: str # base | inline | custom
|
|
25
|
+
description: str
|
|
26
|
+
default_params: dict = field(default_factory=dict)
|
|
27
|
+
# How this VNF maps onto the REAL gRouter data-plane module (gpipe): (module_name,
|
|
28
|
+
# arg_param_key or None). None => illustrative (no real backend yet — labeled as such).
|
|
29
|
+
gpipe: tuple | None = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def real(self) -> bool:
|
|
33
|
+
"""True if this service function has a real gRouter data-plane backend (deployable
|
|
34
|
+
via `gpipe add …`), False if it's an illustrative teaching stub."""
|
|
35
|
+
return self.gpipe is not None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
BASE: list[ModuleType] = [
|
|
39
|
+
ModuleType("parse", "Parse", "grid", "slate", "base", "Ethernet / ARP / IP parse"),
|
|
40
|
+
ModuleType("route", "Route lookup", "router", "blue", "base", "Longest-prefix match"),
|
|
41
|
+
ModuleType("rewrite", "Rewrite", "send", "blue", "base", "Dec TTL, checksum, next-hop MAC"),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# Inline VNFs (service functions). `gpipe` names the gRouter data-plane function that
|
|
45
|
+
# actually runs, so a module carrying one is REAL — it is programmed into the router and
|
|
46
|
+
# moves packets. Every inline VNF now qualifies: classify and tap were the last two
|
|
47
|
+
# illustrative entries, and the QoS and Tap work gave both a backend.
|
|
48
|
+
#
|
|
49
|
+
# The illustrative modules are now the CUSTOM ones below: a Lua or native VNF is real
|
|
50
|
+
# only once you have written and compiled it, which is the point of that tier.
|
|
51
|
+
INLINE: list[ModuleType] = [
|
|
52
|
+
ModuleType("acl", "ACL / Firewall", "firewall", "amber", "inline",
|
|
53
|
+
"Stateless packet filter (drop a CIDR)", {"deny": "10.0.3.0/24"},
|
|
54
|
+
gpipe=("acl", "deny")),
|
|
55
|
+
ModuleType("nat", "NAT", "gateway", "indigo", "inline",
|
|
56
|
+
"Source NAT / masquerade to an address", {"ip": "203.0.113.1"},
|
|
57
|
+
gpipe=("nat", "ip")),
|
|
58
|
+
ModuleType("block", "Block IP", "firewall", "red", "inline",
|
|
59
|
+
"Drop packets to a destination IP (native Zig module)", {"ip": "10.0.3.5"},
|
|
60
|
+
gpipe=("block", "ip")),
|
|
61
|
+
ModuleType("rate", "Rate limit", "queue", "green", "inline",
|
|
62
|
+
"Token-bucket policer — drops packets that exceed a set rate",
|
|
63
|
+
{"spec": "100/200"}, gpipe=("rate", "spec")),
|
|
64
|
+
ModuleType("classify", "QoS classifier", "layout", "teal", "inline",
|
|
65
|
+
"Mark matching traffic with a DSCP class", {"spec": "10.0.3.0/24:ef"},
|
|
66
|
+
gpipe=("classify", "spec")),
|
|
67
|
+
ModuleType("tap", "Tap / capture", "link", "purple", "inline",
|
|
68
|
+
"Mirror matching packets to a .pcap under /captures "
|
|
69
|
+
"(host: ~/.gini/captures) — open it in Wireshark",
|
|
70
|
+
{"path": "/captures/cap.pcap"}, gpipe=("tap", "path")),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
# Inline VNFs you write yourself — a Lua script (interpreted per packet) or a native module you
|
|
74
|
+
# compile in. Both share the same gpipe seam as the built-in native functions once implemented.
|
|
75
|
+
CUSTOM: list[ModuleType] = [
|
|
76
|
+
ModuleType("lua", "Lua VNF", "compile", "cyan", "custom",
|
|
77
|
+
"Per-packet Lua hook — a `process(pkt, ctx)` function you write",
|
|
78
|
+
{"script": "function process(pkt, ctx)\n return CONTINUE\nend"}),
|
|
79
|
+
ModuleType("native", "Native VNF", "controller", "purple", "custom",
|
|
80
|
+
"A native (Zig / C) data-plane module you compile in", {}),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
MODULE_BY_KEY: dict[str, ModuleType] = {m.key: m for m in (*BASE, *INLINE, *CUSTOM)}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class ModuleInstance:
|
|
88
|
+
type_key: str
|
|
89
|
+
name: str
|
|
90
|
+
params: dict = field(default_factory=dict)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def type(self) -> ModuleType:
|
|
94
|
+
return MODULE_BY_KEY[self.type_key]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class Stage:
|
|
99
|
+
label: str
|
|
100
|
+
kind: str # ingress | mode | base | inline | egress
|
|
101
|
+
key: str | None # module type key (None for ingress/egress)
|
|
102
|
+
accent: str
|
|
103
|
+
locked: bool
|
|
104
|
+
index: int | None # index into program.inline (for inline stages), else None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _ip_in_cidr(ip: str, cidr: str) -> bool:
|
|
108
|
+
"""True if dotted-quad `ip` falls inside `cidr` (e.g. '10.0.2.0/24'). Mirrors the C ACL's
|
|
109
|
+
mask-and-compare, so the offline trace agrees with what the real gRouter would drop."""
|
|
110
|
+
try:
|
|
111
|
+
net, _, bits = cidr.partition("/")
|
|
112
|
+
bits = int(bits) if bits else 32
|
|
113
|
+
to_int = lambda a: sum(int(o) << (24 - 8 * i) for i, o in enumerate(a.split(".")))
|
|
114
|
+
mask = (0xffffffff << (32 - bits)) & 0xffffffff if bits else 0
|
|
115
|
+
return (to_int(ip) & mask) == (to_int(net) & mask)
|
|
116
|
+
except Exception:
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class RouterProgram:
|
|
121
|
+
def __init__(self) -> None:
|
|
122
|
+
self.mode = "legacy" # 'legacy' | 'openflow'
|
|
123
|
+
self.inline: list[ModuleInstance] = []
|
|
124
|
+
self.classifier = "" # which traffic enters the chain ("" = all)
|
|
125
|
+
|
|
126
|
+
# SFC: classifier + deploy to the real gRouter -------------------------- #
|
|
127
|
+
def set_classifier(self, expr: str) -> None:
|
|
128
|
+
self.classifier = (expr or "").strip()
|
|
129
|
+
|
|
130
|
+
def deploy_commands(self) -> list[str]:
|
|
131
|
+
"""The `gpipe` argument-lines that program THIS chain into the running gRouter (each
|
|
132
|
+
is sent as `gpipe <cmd>`): clear, then add each service function that has a real
|
|
133
|
+
data-plane backend, in order. Illustrative modules are skipped."""
|
|
134
|
+
cmds = ["clear"]
|
|
135
|
+
for inst in self.inline:
|
|
136
|
+
g = inst.type.gpipe
|
|
137
|
+
if g is None:
|
|
138
|
+
continue
|
|
139
|
+
name, argkey = g
|
|
140
|
+
arg = inst.params.get(argkey) if argkey else None
|
|
141
|
+
cmds.append(f"add {name} {arg}" if arg else f"add {name}")
|
|
142
|
+
return cmds
|
|
143
|
+
|
|
144
|
+
def illustrative(self) -> list[ModuleInstance]:
|
|
145
|
+
"""Inline functions with no real gRouter backend yet (shown, not deployed)."""
|
|
146
|
+
return [i for i in self.inline if i.type.gpipe is None]
|
|
147
|
+
|
|
148
|
+
# editing -------------------------------------------------------------- #
|
|
149
|
+
def add(self, type_key: str) -> ModuleInstance:
|
|
150
|
+
mt = MODULE_BY_KEY[type_key]
|
|
151
|
+
inst = ModuleInstance(type_key, mt.label, dict(mt.default_params))
|
|
152
|
+
self.inline.append(inst)
|
|
153
|
+
return inst
|
|
154
|
+
|
|
155
|
+
def remove(self, index: int) -> None:
|
|
156
|
+
if 0 <= index < len(self.inline):
|
|
157
|
+
self.inline.pop(index)
|
|
158
|
+
|
|
159
|
+
def move(self, index: int, delta: int) -> None:
|
|
160
|
+
j = index + delta
|
|
161
|
+
if 0 <= index < len(self.inline) and 0 <= j < len(self.inline):
|
|
162
|
+
self.inline[index], self.inline[j] = self.inline[j], self.inline[index]
|
|
163
|
+
|
|
164
|
+
def set_mode(self, mode: str) -> None:
|
|
165
|
+
self.mode = "openflow" if mode == "openflow" else "legacy"
|
|
166
|
+
|
|
167
|
+
# the canonical ordered pipeline --------------------------------------- #
|
|
168
|
+
def stages(self) -> list[Stage]:
|
|
169
|
+
out: list[Stage] = [Stage("ingress", "ingress", None, "slate", True, None)]
|
|
170
|
+
if self.mode == "openflow":
|
|
171
|
+
out.append(Stage("OpenFlow flow table", "mode", "openflow", "teal", True, None))
|
|
172
|
+
parse = BASE[0]
|
|
173
|
+
out.append(Stage(parse.label, "base", parse.key, parse.accent, True, None))
|
|
174
|
+
for i, inst in enumerate(self.inline):
|
|
175
|
+
mt = inst.type
|
|
176
|
+
out.append(Stage(inst.name, "inline", mt.key, mt.accent, False, i))
|
|
177
|
+
for mt in BASE[1:]:
|
|
178
|
+
out.append(Stage(mt.label, "base", mt.key, mt.accent, True, None))
|
|
179
|
+
out.append(Stage("egress", "egress", None, "slate", True, None))
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
# local packet trace (drives the step debugger) ------------------------ #
|
|
183
|
+
def trace(self, dst: str = "10.0.2.10") -> list[str]:
|
|
184
|
+
verdicts: list[str] = []
|
|
185
|
+
dropped = False
|
|
186
|
+
for st in self.stages():
|
|
187
|
+
if dropped:
|
|
188
|
+
verdicts.append("—")
|
|
189
|
+
continue
|
|
190
|
+
if st.kind == "ingress":
|
|
191
|
+
verdicts.append(f"in · dst {dst}")
|
|
192
|
+
elif st.kind == "mode":
|
|
193
|
+
verdicts.append("match → NORMAL (to legacy)")
|
|
194
|
+
elif st.kind == "egress":
|
|
195
|
+
verdicts.append("sent ✓")
|
|
196
|
+
elif st.key == "acl":
|
|
197
|
+
deny = self.inline[st.index].params.get("deny", "")
|
|
198
|
+
if deny and _ip_in_cidr(dst, deny):
|
|
199
|
+
verdicts.append(f"DROP (matches deny {deny})")
|
|
200
|
+
dropped = True
|
|
201
|
+
else:
|
|
202
|
+
verdicts.append("pass")
|
|
203
|
+
elif st.key == "block":
|
|
204
|
+
tgt = self.inline[st.index].params.get("ip", "")
|
|
205
|
+
if tgt and dst == tgt:
|
|
206
|
+
verdicts.append(f"DROP (blocks {tgt})")
|
|
207
|
+
dropped = True
|
|
208
|
+
else:
|
|
209
|
+
verdicts.append("pass")
|
|
210
|
+
elif st.key == "nat":
|
|
211
|
+
verdicts.append("rewrite source · continue")
|
|
212
|
+
elif st.key == "route":
|
|
213
|
+
verdicts.append("→ next-hop eth1")
|
|
214
|
+
elif st.key == "rewrite":
|
|
215
|
+
verdicts.append("ttl-- · checksum")
|
|
216
|
+
elif st.key == "rate":
|
|
217
|
+
verdicts.append("policer · within rate → pass")
|
|
218
|
+
elif st.key == "classify":
|
|
219
|
+
verdicts.append("mark DSCP · continue")
|
|
220
|
+
elif st.key == "tap":
|
|
221
|
+
verdicts.append("mirror → pcap · continue")
|
|
222
|
+
else:
|
|
223
|
+
verdicts.append("pass")
|
|
224
|
+
return verdicts
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Routing/ARP-table parsing — turn the gRouter's `route` and `arp` CLI dumps into rows for
|
|
2
|
+
the Router Lab's routing view.
|
|
3
|
+
|
|
4
|
+
A regular router is the C gRouter; at runtime `element_query(router, "route show")` prints
|
|
5
|
+
its route table and `element_query(router, "arp show")` its ARP cache (the gRouter CLI needs
|
|
6
|
+
the `show` subcommand — a bare `route`/`arp` prints nothing). This module parses both.
|
|
7
|
+
Pure/text-only, so it's unit-tested without Docker.
|
|
8
|
+
|
|
9
|
+
`route` output (from routetable.c printRouteTable):
|
|
10
|
+
Index Network Netmask Nexthop Interface
|
|
11
|
+
[0] 10.0.1.0 255.255.255.0 0.0.0.0 tun1
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
_ROUTE_RE = re.compile(r"^\[(\d+)\]\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s*$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class RouteEntry:
|
|
23
|
+
index: int
|
|
24
|
+
network: str
|
|
25
|
+
netmask: str
|
|
26
|
+
nexthop: str # 0.0.0.0 == directly connected
|
|
27
|
+
iface: str
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def direct(self) -> bool:
|
|
31
|
+
return self.nexthop in ("0.0.0.0", "*", "", "0")
|
|
32
|
+
|
|
33
|
+
def nexthop_str(self) -> str:
|
|
34
|
+
return "direct (on-link)" if self.direct else self.nexthop
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_routes(text: str) -> list[RouteEntry]:
|
|
38
|
+
"""Rows of the gRouter route table (lines like `[0] net mask nexthop iface`)."""
|
|
39
|
+
out: list[RouteEntry] = []
|
|
40
|
+
for line in (text or "").splitlines():
|
|
41
|
+
m = _ROUTE_RE.match(line.strip())
|
|
42
|
+
if m:
|
|
43
|
+
out.append(RouteEntry(int(m.group(1)), m.group(2), m.group(3),
|
|
44
|
+
m.group(4), m.group(5)))
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ARP cache: the gRouter prints IP<->MAC pairs; be tolerant about exact columns and just
|
|
49
|
+
# pull the first dotted-quad IP and first MAC on each line.
|
|
50
|
+
_IP_RE = re.compile(r"\b(\d{1,3}(?:\.\d{1,3}){3})\b")
|
|
51
|
+
_MAC_RE = re.compile(r"\b([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})\b")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ArpEntry:
|
|
56
|
+
ip: str
|
|
57
|
+
mac: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_arp(text: str) -> list[ArpEntry]:
|
|
61
|
+
out: list[ArpEntry] = []
|
|
62
|
+
for line in (text or "").splitlines():
|
|
63
|
+
ip = _IP_RE.search(line)
|
|
64
|
+
mac = _MAC_RE.search(line)
|
|
65
|
+
if ip and mac:
|
|
66
|
+
out.append(ArpEntry(ip.group(1), mac.group(1)))
|
|
67
|
+
return out
|
gini/domain/scoring.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Scoring — objective results → completion + a broad band.
|
|
2
|
+
|
|
3
|
+
Completion is deterministic (GINI's runtime supplies the objective facts; the AI never scores).
|
|
4
|
+
We check *completion*, not quality: how many objectives are met, whether `complete_when` is
|
|
5
|
+
satisfied, and whether it was done on time / within attempts. Bands are intentionally coarse.
|
|
6
|
+
|
|
7
|
+
`complete_when`: `all` | `any` | `at_least(n)`. A `pending` (behavioral, un-run) objective counts
|
|
8
|
+
as not-yet-met.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from .objectives import ObjectiveResult
|
|
16
|
+
|
|
17
|
+
GOLD, PASS, PARTIAL, INCOMPLETE = "gold", "pass", "partial", "incomplete"
|
|
18
|
+
|
|
19
|
+
_AT_LEAST = re.compile(r"at_least\(\s*(\d+)\s*\)")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Score:
|
|
24
|
+
band: str
|
|
25
|
+
met: int
|
|
26
|
+
total: int
|
|
27
|
+
complete: bool
|
|
28
|
+
pending: int # behavioral objectives not yet run
|
|
29
|
+
forks_done: int = 0 # how many difficulty forks the student fully completed
|
|
30
|
+
forks_total: int = 0 # forks this mission offers (0 = a plain, fork-less mission)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def summary(self) -> str:
|
|
34
|
+
s = f"{self.met}/{self.total} objectives"
|
|
35
|
+
if self.pending:
|
|
36
|
+
s += f" ({self.pending} awaiting a run)"
|
|
37
|
+
if self.forks_total:
|
|
38
|
+
s += f" · {self.forks_done}/{self.forks_total} harder forks"
|
|
39
|
+
return s
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_complete(results, complete_when: str = "all") -> bool:
|
|
43
|
+
met = sum(1 for r in results if r.met)
|
|
44
|
+
total = len(results)
|
|
45
|
+
cw = (complete_when or "all").strip()
|
|
46
|
+
if cw == "any":
|
|
47
|
+
return met >= 1
|
|
48
|
+
m = _AT_LEAST.fullmatch(cw)
|
|
49
|
+
if m:
|
|
50
|
+
return met >= int(m.group(1))
|
|
51
|
+
return met == total and total > 0 # 'all' (default)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def score(results, *, complete_when: str = "all", on_time: bool = True,
|
|
55
|
+
forks_done: int = 0, forks_total: int = 0) -> Score:
|
|
56
|
+
"""Compute the band from CORE objective results, lifted by difficulty forks.
|
|
57
|
+
|
|
58
|
+
Fork-less mission (forks_total == 0) — unchanged, so nothing regresses:
|
|
59
|
+
gold = complete AND on time · pass = complete · partial = some met · incomplete = none.
|
|
60
|
+
|
|
61
|
+
Forked mission — the fork is the difficulty knob (GINI_AUTHORING_DESIGN.md):
|
|
62
|
+
the golden (easy) path completing earns PASS; **gold now means you also took a harder fork**.
|
|
63
|
+
So a student can't coast to gold on the easy path — gold is reserved for going deeper.
|
|
64
|
+
"""
|
|
65
|
+
results = list(results)
|
|
66
|
+
met = sum(1 for r in results if r.met)
|
|
67
|
+
pending = sum(1 for r in results if r.status == "pending")
|
|
68
|
+
complete = is_complete(results, complete_when) # completion is CORE-only; forks are extra
|
|
69
|
+
if not complete:
|
|
70
|
+
band = PARTIAL if met > 0 else INCOMPLETE
|
|
71
|
+
elif forks_total == 0:
|
|
72
|
+
band = GOLD if on_time else PASS # legacy semantics preserved exactly
|
|
73
|
+
else:
|
|
74
|
+
band = GOLD if (forks_done >= 1 and on_time) else PASS
|
|
75
|
+
return Score(band=band, met=met, total=len(results), complete=complete, pending=pending,
|
|
76
|
+
forks_done=forks_done, forks_total=forks_total)
|