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,374 @@
|
|
|
1
|
+
"""Set up a GINI32 board over USB, from inside gBuilder.
|
|
2
|
+
|
|
3
|
+
A board needs exactly one thing it cannot get over the air: the lab Wi-Fi
|
|
4
|
+
credentials. Everything else — its address, gateway, hotspot SSID, physical
|
|
5
|
+
subnet — comes from the canvas once it is online. So this module exists to carry
|
|
6
|
+
that one secret across the wire, and then get out of the way: a student touches
|
|
7
|
+
USB once per board, ever.
|
|
8
|
+
|
|
9
|
+
The transport is the board's own serial console (`gini> `), driven exactly as a
|
|
10
|
+
human would drive it. That means no second protocol to keep in sync with the
|
|
11
|
+
firmware — if a command works when typed, it works here.
|
|
12
|
+
|
|
13
|
+
Standard library only. `pyserial` is used *if* it happens to be importable
|
|
14
|
+
because it enumerates ports more informatively, but it is not required and is
|
|
15
|
+
not a declared dependency: gBuilder is installed by students, and this must work
|
|
16
|
+
on a machine with nothing extra on it.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import glob
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
|
|
27
|
+
BAUD = 115200
|
|
28
|
+
PROMPT = "gini> "
|
|
29
|
+
BANNER = "gBridge console"
|
|
30
|
+
|
|
31
|
+
# How long to wait for the prompt after opening. Opening the port pulls DTR/RTS,
|
|
32
|
+
# which on most dev boards is wired to EN/BOOT and reboots the ESP32 — so the
|
|
33
|
+
# first thing we see is a boot log, and the prompt only arrives after it.
|
|
34
|
+
BOOT_WAIT = 6.0
|
|
35
|
+
CMD_WAIT = 3.0
|
|
36
|
+
|
|
37
|
+
# Serial devices that are worth *asking* whether they are a board. On macOS we
|
|
38
|
+
# must use the `cu.*` ("call-up") node: opening `tty.*` blocks until carrier
|
|
39
|
+
# detect, which for a USB adapter never comes, and the app would hang on open.
|
|
40
|
+
_PATTERNS = (
|
|
41
|
+
"/dev/cu.usbserial*", # FTDI
|
|
42
|
+
"/dev/cu.SLAB_USBtoUART*", # CP210x (many ESP32 devkits)
|
|
43
|
+
"/dev/cu.wchusbserial*", # CH340 (very common on cheap boards)
|
|
44
|
+
"/dev/cu.usbmodem*", # native USB-JTAG (ESP32-S3/C3)
|
|
45
|
+
"/dev/ttyUSB*", # Linux
|
|
46
|
+
"/dev/ttyACM*", # Linux
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Never a board, and present in numbers: macOS ships Bluetooth/debug nodes, and a
|
|
50
|
+
# Linux box typically exposes 32 legacy motherboard UARTs (/dev/ttyS0..31). Listing
|
|
51
|
+
# those would bury the one device the student actually plugged in.
|
|
52
|
+
_NOISE = re.compile(r"(Bluetooth|debug-console|wlan-debug)", re.I)
|
|
53
|
+
_NEVER = re.compile(r"^/dev/ttyS\d+$")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _plausible(device: str) -> bool:
|
|
57
|
+
"""Could this device be a board? Cheap name test, before we try talking to it."""
|
|
58
|
+
import fnmatch
|
|
59
|
+
if _NOISE.search(device) or _NEVER.match(device):
|
|
60
|
+
return False
|
|
61
|
+
return any(fnmatch.fnmatch(device, pat) for pat in _PATTERNS)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class PortInfo:
|
|
66
|
+
device: str
|
|
67
|
+
description: str = ""
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def label(self) -> str:
|
|
71
|
+
return f"{self.device} — {self.description}" if self.description else self.device
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class BoardInfo:
|
|
76
|
+
"""What a board says about itself, from `show`."""
|
|
77
|
+
port: str
|
|
78
|
+
board_id: str = ""
|
|
79
|
+
ssid: str = ""
|
|
80
|
+
has_password: bool = False
|
|
81
|
+
server: str = ""
|
|
82
|
+
ap_ssid: str = ""
|
|
83
|
+
# The laptop holding this board's claim, "" if unclaimed. Comes from `status`, not
|
|
84
|
+
# `show` — the claim is link state, not a setting — but it is read on the same open,
|
|
85
|
+
# because opening the port reboots the board and doing it twice is a wasted six
|
|
86
|
+
# seconds per board.
|
|
87
|
+
owner: str = ""
|
|
88
|
+
extra: dict = field(default_factory=dict)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def configured(self) -> bool:
|
|
92
|
+
"""Has this board been set up before? (A fresh one still has the default.)"""
|
|
93
|
+
return bool(self.ssid) and self.has_password
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def list_ports() -> list[PortInfo]:
|
|
97
|
+
"""Serial devices that could plausibly be a board, best-effort and never raising.
|
|
98
|
+
|
|
99
|
+
Uses pyserial when available (it knows the USB descriptor strings, which make
|
|
100
|
+
the picker much friendlier); otherwise falls back to globbing the device names.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
from serial.tools import list_ports as _lp # type: ignore
|
|
104
|
+
out = []
|
|
105
|
+
for p in _lp.comports():
|
|
106
|
+
if sys.platform == "darwin" and "/tty." in p.device:
|
|
107
|
+
continue # prefer the cu.* twin
|
|
108
|
+
if not _plausible(p.device):
|
|
109
|
+
continue # ttyS0..31, Bluetooth, …
|
|
110
|
+
out.append(PortInfo(p.device, (p.description or "").strip()))
|
|
111
|
+
if out:
|
|
112
|
+
return sorted(out, key=lambda p: p.device)
|
|
113
|
+
except Exception:
|
|
114
|
+
pass # fall through to globbing
|
|
115
|
+
|
|
116
|
+
seen: list[PortInfo] = []
|
|
117
|
+
for pat in _PATTERNS:
|
|
118
|
+
for dev in glob.glob(pat):
|
|
119
|
+
if _plausible(dev):
|
|
120
|
+
seen.append(PortInfo(dev))
|
|
121
|
+
return sorted({p.device: p for p in seen}.values(), key=lambda p: p.device)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class BoardConsole:
|
|
125
|
+
"""A conversation with one board over its serial console.
|
|
126
|
+
|
|
127
|
+
Deliberately dumb: write a line, read until the prompt comes back. The board
|
|
128
|
+
echoes what it receives, so every reply contains the command itself; callers
|
|
129
|
+
get the text with that echo stripped.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(self, port: str, baud: int = BAUD) -> None:
|
|
133
|
+
self.port = port
|
|
134
|
+
self.baud = baud
|
|
135
|
+
self.fd = -1
|
|
136
|
+
self.transcript = "" # everything read, for diagnostics
|
|
137
|
+
|
|
138
|
+
# -- lifecycle -- #
|
|
139
|
+
|
|
140
|
+
def open(self) -> None:
|
|
141
|
+
# O_NONBLOCK so the open itself cannot hang even on a misbehaving device;
|
|
142
|
+
# O_NOCTTY so this never becomes our controlling terminal.
|
|
143
|
+
self.fd = os.open(self.port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
|
144
|
+
try:
|
|
145
|
+
self._configure()
|
|
146
|
+
except Exception:
|
|
147
|
+
self.close()
|
|
148
|
+
raise
|
|
149
|
+
|
|
150
|
+
def _configure(self) -> None:
|
|
151
|
+
"""Raw mode at the board's baud rate: no echo, no line editing, 8N1."""
|
|
152
|
+
import termios
|
|
153
|
+
attrs = termios.tcgetattr(self.fd)
|
|
154
|
+
iflag, oflag, cflag, lflag, ispeed, ospeed, cc = attrs
|
|
155
|
+
# no translation, no flow control, no parity/canonical/echo
|
|
156
|
+
iflag &= ~(termios.IXON | termios.IXOFF | termios.IXANY | termios.ICRNL
|
|
157
|
+
| termios.INLCR | termios.IGNCR | termios.ISTRIP | termios.INPCK)
|
|
158
|
+
oflag &= ~termios.OPOST
|
|
159
|
+
lflag &= ~(termios.ECHO | termios.ECHOE | termios.ECHONL
|
|
160
|
+
| termios.ICANON | termios.ISIG | termios.IEXTEN)
|
|
161
|
+
cflag &= ~(termios.PARENB | termios.CSTOPB | termios.CSIZE)
|
|
162
|
+
cflag |= termios.CS8 | termios.CREAD | termios.CLOCAL # CLOCAL: ignore carrier
|
|
163
|
+
speed = getattr(termios, f"B{self.baud}", termios.B115200)
|
|
164
|
+
cc = list(cc)
|
|
165
|
+
cc[termios.VMIN] = 0
|
|
166
|
+
cc[termios.VTIME] = 0
|
|
167
|
+
termios.tcsetattr(self.fd, termios.TCSANOW,
|
|
168
|
+
[iflag, oflag, cflag, lflag, speed, speed, cc])
|
|
169
|
+
|
|
170
|
+
def close(self) -> None:
|
|
171
|
+
if self.fd >= 0:
|
|
172
|
+
try:
|
|
173
|
+
os.close(self.fd)
|
|
174
|
+
except OSError:
|
|
175
|
+
pass
|
|
176
|
+
self.fd = -1
|
|
177
|
+
|
|
178
|
+
def __enter__(self) -> "BoardConsole":
|
|
179
|
+
self.open()
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
def __exit__(self, *exc) -> None:
|
|
183
|
+
self.close()
|
|
184
|
+
|
|
185
|
+
# -- raw io -- #
|
|
186
|
+
|
|
187
|
+
def _read(self, timeout: float) -> str:
|
|
188
|
+
import select
|
|
189
|
+
try:
|
|
190
|
+
r, _, _ = select.select([self.fd], [], [], timeout)
|
|
191
|
+
except (OSError, ValueError):
|
|
192
|
+
return ""
|
|
193
|
+
if not r:
|
|
194
|
+
return ""
|
|
195
|
+
try:
|
|
196
|
+
data = os.read(self.fd, 4096)
|
|
197
|
+
except (BlockingIOError, OSError):
|
|
198
|
+
return ""
|
|
199
|
+
text = data.decode("utf-8", "replace")
|
|
200
|
+
self.transcript += text
|
|
201
|
+
return text
|
|
202
|
+
|
|
203
|
+
def _write(self, text: str) -> None:
|
|
204
|
+
data = text.encode("utf-8")
|
|
205
|
+
while data:
|
|
206
|
+
try:
|
|
207
|
+
n = os.write(self.fd, data)
|
|
208
|
+
data = data[n:]
|
|
209
|
+
except BlockingIOError:
|
|
210
|
+
time.sleep(0.01)
|
|
211
|
+
|
|
212
|
+
def read_until(self, needle: str, timeout: float) -> str:
|
|
213
|
+
"""Accumulate until `needle` appears. Returns what was read (may lack it)."""
|
|
214
|
+
buf = ""
|
|
215
|
+
deadline = time.monotonic() + timeout
|
|
216
|
+
while time.monotonic() < deadline:
|
|
217
|
+
buf += self._read(min(0.25, max(0.01, deadline - time.monotonic())))
|
|
218
|
+
if needle in buf:
|
|
219
|
+
break
|
|
220
|
+
return buf
|
|
221
|
+
|
|
222
|
+
# -- protocol -- #
|
|
223
|
+
|
|
224
|
+
def wait_for_prompt(self, timeout: float = BOOT_WAIT) -> bool:
|
|
225
|
+
"""Get the board to a prompt.
|
|
226
|
+
|
|
227
|
+
Opening the port usually reset it, so first just listen for the banner;
|
|
228
|
+
if the board was already up (no reset) a bare newline draws the prompt.
|
|
229
|
+
"""
|
|
230
|
+
if PROMPT in self.read_until(PROMPT, min(2.0, timeout)):
|
|
231
|
+
return True
|
|
232
|
+
deadline = time.monotonic() + timeout
|
|
233
|
+
while time.monotonic() < deadline:
|
|
234
|
+
self._write("\r\n")
|
|
235
|
+
if PROMPT in self.read_until(PROMPT, 1.0):
|
|
236
|
+
return True
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
def command(self, line: str, timeout: float = CMD_WAIT) -> str:
|
|
240
|
+
"""Send one command; return its output without the echo or the next prompt."""
|
|
241
|
+
self._write(line + "\r\n")
|
|
242
|
+
out = self.read_until(PROMPT, timeout)
|
|
243
|
+
# the board echoes what we typed; drop that first line
|
|
244
|
+
if line in out:
|
|
245
|
+
out = out.split(line, 1)[1]
|
|
246
|
+
return out.replace(PROMPT, "").strip("\r\n \t")
|
|
247
|
+
|
|
248
|
+
def identify(self) -> BoardInfo | None:
|
|
249
|
+
"""Confirm this really is a GINI32 board and read its current settings.
|
|
250
|
+
|
|
251
|
+
Returns None for anything that does not answer like one — a USB-serial
|
|
252
|
+
adapter is not evidence of firmware, so we ask rather than assume.
|
|
253
|
+
"""
|
|
254
|
+
if not self.wait_for_prompt():
|
|
255
|
+
return None
|
|
256
|
+
out = self.command("show")
|
|
257
|
+
if not out:
|
|
258
|
+
return None
|
|
259
|
+
info = BoardInfo(port=self.port)
|
|
260
|
+
found = 0
|
|
261
|
+
for raw in out.splitlines():
|
|
262
|
+
parts = raw.strip().split(None, 1)
|
|
263
|
+
if len(parts) != 2:
|
|
264
|
+
continue
|
|
265
|
+
key, val = parts[0].strip(), parts[1].strip()
|
|
266
|
+
found += 1
|
|
267
|
+
if key == "id":
|
|
268
|
+
info.board_id = val
|
|
269
|
+
elif key == "ssid":
|
|
270
|
+
info.ssid = val
|
|
271
|
+
elif key == "pass":
|
|
272
|
+
info.has_password = val != "(empty)"
|
|
273
|
+
elif key == "server":
|
|
274
|
+
info.server = val
|
|
275
|
+
elif key == "apssid":
|
|
276
|
+
info.ap_ssid = val
|
|
277
|
+
else:
|
|
278
|
+
info.extra[key] = val
|
|
279
|
+
# `show` always prints several keys; anything less is not our console
|
|
280
|
+
if found < 3 or (not info.board_id and not info.ssid):
|
|
281
|
+
return None
|
|
282
|
+
# Who owns it. Best-effort: an older firmware has no `claim:` line, and not
|
|
283
|
+
# knowing the owner must never make a board look unidentifiable.
|
|
284
|
+
try:
|
|
285
|
+
st = self.command("status")
|
|
286
|
+
m = re.search(r"^claim:\s*(.+)$", st, re.M)
|
|
287
|
+
if m:
|
|
288
|
+
claim = m.group(1).strip()
|
|
289
|
+
info.owner = "" if claim.lower().startswith("unclaimed") else claim
|
|
290
|
+
except OSError:
|
|
291
|
+
pass
|
|
292
|
+
return info
|
|
293
|
+
|
|
294
|
+
def apply(self, ssid: str, password: str, board_id: str,
|
|
295
|
+
server: str = "auto", reboot: bool = True) -> tuple[bool, str]:
|
|
296
|
+
"""Write the settings a board cannot discover, persist them, and restart.
|
|
297
|
+
|
|
298
|
+
Returns (ok, message). Every step is checked: a `set` that the firmware
|
|
299
|
+
rejected must not be reported as success, or a student ends up hunting a
|
|
300
|
+
network fault that is really a typo.
|
|
301
|
+
"""
|
|
302
|
+
steps = [("ssid", ssid), ("pass", password), ("id", board_id)]
|
|
303
|
+
if server:
|
|
304
|
+
steps.append(("server", server))
|
|
305
|
+
for key, val in steps:
|
|
306
|
+
if val is None:
|
|
307
|
+
continue
|
|
308
|
+
out = self.command(f"set {key} {val}")
|
|
309
|
+
if "ok" not in out.lower():
|
|
310
|
+
return False, f"the board did not accept `set {key}`: {out.strip()[:120]}"
|
|
311
|
+
out = self.command("save", timeout=5.0)
|
|
312
|
+
if "saved" not in out.lower():
|
|
313
|
+
return False, f"the board did not save its settings: {out.strip()[:120]}"
|
|
314
|
+
if reboot:
|
|
315
|
+
self._write("reboot\r\n")
|
|
316
|
+
time.sleep(0.3) # it is going away; nothing useful to read back
|
|
317
|
+
return True, f"'{board_id}' set up for network '{ssid}'"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def unpair(self) -> tuple[bool, str]:
|
|
321
|
+
"""Release this board's claim, so another laptop can adopt it.
|
|
322
|
+
|
|
323
|
+
This is the deliberate counterpart to there being NO automatic release: a claimed
|
|
324
|
+
board must never re-open itself while its owner is away from the bench, so the
|
|
325
|
+
only way back is physical possession — and USB is what "physical possession"
|
|
326
|
+
means in software. It is also the honest recovery path for the classroom failure
|
|
327
|
+
the claim mechanism exists to prevent: a board adopted by the wrong laptop, which
|
|
328
|
+
from the student's side looks like a board that simply refuses to appear.
|
|
329
|
+
"""
|
|
330
|
+
out = self.command("unpair", timeout=5.0)
|
|
331
|
+
low = out.lower()
|
|
332
|
+
if "already unclaimed" in low:
|
|
333
|
+
return True, "this board was not claimed by anyone — nothing to release"
|
|
334
|
+
if "released" in low:
|
|
335
|
+
# Echo the previous owner back: in a lab it matters WHOSE board you just took.
|
|
336
|
+
m = re.search(r"released from '([^']*)'", out)
|
|
337
|
+
who = m.group(1) if m else ""
|
|
338
|
+
return True, (f"released from {who} — any gBuilder may now claim it" if who
|
|
339
|
+
else "released — any gBuilder may now claim it")
|
|
340
|
+
return False, f"the board did not confirm the release: {out.strip()[:120]}"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def detect_boards(ports: list[PortInfo] | None = None) -> tuple[list[BoardInfo], list[PortInfo]]:
|
|
344
|
+
"""Ask every candidate port whether it is a board.
|
|
345
|
+
|
|
346
|
+
Returns (boards, others) so the UI can both offer the real boards and say
|
|
347
|
+
something useful about the serial devices that did not answer.
|
|
348
|
+
"""
|
|
349
|
+
boards: list[BoardInfo] = []
|
|
350
|
+
others: list[PortInfo] = []
|
|
351
|
+
for p in (ports if ports is not None else list_ports()):
|
|
352
|
+
try:
|
|
353
|
+
with BoardConsole(p.device) as con:
|
|
354
|
+
info = con.identify()
|
|
355
|
+
except (OSError, ImportError):
|
|
356
|
+
info = None
|
|
357
|
+
if info is not None:
|
|
358
|
+
boards.append(info)
|
|
359
|
+
else:
|
|
360
|
+
others.append(p)
|
|
361
|
+
return boards, others
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def suggest_board_id(existing: list[str]) -> str:
|
|
365
|
+
"""Next free `gini-N`, so a TA setting up a stack of boards never repeats one."""
|
|
366
|
+
used = set()
|
|
367
|
+
for e in existing:
|
|
368
|
+
m = re.fullmatch(r"gini-(\d+)", (e or "").strip())
|
|
369
|
+
if m:
|
|
370
|
+
used.add(int(m.group(1)))
|
|
371
|
+
n = 1
|
|
372
|
+
while n in used:
|
|
373
|
+
n += 1
|
|
374
|
+
return f"gini-{n}"
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Cloud service catalog — maps a palette element to a real, off-the-shelf container.
|
|
2
|
+
|
|
3
|
+
The cloud-course half of GINI: instead of simulating these on the custom UDP/tun
|
|
4
|
+
fabric (that's for the networking course), each managed-cloud element runs as a normal
|
|
5
|
+
container from a public image on the shared `gini` Docker network, reachable by its
|
|
6
|
+
service name — just like real cloud service discovery. Students draw the architecture
|
|
7
|
+
in gBuilder, press Run, and get actual services they can inspect, drive, and visualise.
|
|
8
|
+
|
|
9
|
+
Each `Port(container, label, web)` with web=True is an HTTP console worth opening in a
|
|
10
|
+
browser; the compiler assigns a unique host port so several services don't collide.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Port:
|
|
19
|
+
container: int
|
|
20
|
+
label: str
|
|
21
|
+
web: bool = False # True => an HTTP console to open in the browser
|
|
22
|
+
path: str = "" # console URL path (some UIs aren't at the root, e.g. Fortio)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class CloudService:
|
|
27
|
+
image: str
|
|
28
|
+
summary: str # one line shown in the inspector / to GINI
|
|
29
|
+
command: tuple[str, ...] = ()
|
|
30
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
31
|
+
ports: tuple[Port, ...] = ()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# element type_key -> the container that backs it
|
|
35
|
+
CATALOG: dict[str, CloudService] = {
|
|
36
|
+
"object_store": CloudService(
|
|
37
|
+
image="minio/minio:RELEASE.2024-10-13T13-34-11Z",
|
|
38
|
+
summary="MinIO — an S3-compatible object store. Use the AWS CLI or `mc`.",
|
|
39
|
+
command=("server", "/data", "--console-address", ":9001"),
|
|
40
|
+
env={"MINIO_ROOT_USER": "minioadmin", "MINIO_ROOT_PASSWORD": "minioadmin"},
|
|
41
|
+
ports=(Port(9001, "console", web=True), Port(9000, "s3"))),
|
|
42
|
+
"database": CloudService(
|
|
43
|
+
image="postgres:16-alpine",
|
|
44
|
+
summary="PostgreSQL managed database. Connect with psql on port 5432.",
|
|
45
|
+
env={"POSTGRES_USER": "gini", "POSTGRES_PASSWORD": "gini", "POSTGRES_DB": "app"},
|
|
46
|
+
ports=(Port(5432, "postgres"),)),
|
|
47
|
+
"queue": CloudService(
|
|
48
|
+
image="rabbitmq:3-management-alpine",
|
|
49
|
+
summary="RabbitMQ message broker with a management console.",
|
|
50
|
+
ports=(Port(15672, "console", web=True), Port(5672, "amqp"))),
|
|
51
|
+
"load_balancer": CloudService(
|
|
52
|
+
image="nginx:alpine",
|
|
53
|
+
summary="nginx reverse proxy / load balancer fronting backend targets.",
|
|
54
|
+
ports=(Port(80, "http", web=True),)),
|
|
55
|
+
"registry": CloudService(
|
|
56
|
+
image="registry:2",
|
|
57
|
+
summary="Docker/OCI image registry serving container images on port 5000.",
|
|
58
|
+
ports=(Port(5000, "registry"),)),
|
|
59
|
+
|
|
60
|
+
# --- serverless front door ---
|
|
61
|
+
"api_gateway": CloudService(
|
|
62
|
+
image="traefik:v3.1",
|
|
63
|
+
summary="API Gateway (Traefik) — maps a URL path to each connected serverless "
|
|
64
|
+
"Function. Open the dashboard to watch routing live.",
|
|
65
|
+
command=("--api.insecure=true", "--entrypoints.web.address=:80",
|
|
66
|
+
"--metrics.prometheus=true"),
|
|
67
|
+
ports=(Port(8080, "dashboard", web=True, path="/dashboard/"), Port(80, "http"))),
|
|
68
|
+
|
|
69
|
+
# --- edge & traffic ---
|
|
70
|
+
"proxy": CloudService(
|
|
71
|
+
image="traefik:v3.1",
|
|
72
|
+
summary="Traefik reverse proxy / edge router. Dashboard shows routing live.",
|
|
73
|
+
command=("--api.insecure=true", "--entrypoints.web.address=:80",
|
|
74
|
+
"--metrics.prometheus=true"), # exposes /metrics for the cloud fabric
|
|
75
|
+
ports=(Port(8080, "dashboard", web=True, path="/dashboard/"), Port(80, "http"))),
|
|
76
|
+
"web_app": CloudService(
|
|
77
|
+
image="nginxdemos/hello:plain-text",
|
|
78
|
+
summary="A demo web backend that displays its hostname/IP — put several behind "
|
|
79
|
+
"a load balancer to see requests spread across them.",
|
|
80
|
+
ports=(Port(80, "http", web=True),)),
|
|
81
|
+
|
|
82
|
+
# --- streaming & messaging ---
|
|
83
|
+
"stream": CloudService(
|
|
84
|
+
image="redpandadata/redpanda:v24.2.7",
|
|
85
|
+
summary="Redpanda — a Kafka-API event streaming log. Produce/consume with any "
|
|
86
|
+
"Kafka client against port 9092.",
|
|
87
|
+
command=("redpanda", "start", "--mode", "dev-container", "--smp", "1",
|
|
88
|
+
"--advertise-kafka-addr", "{svc}"),
|
|
89
|
+
ports=(Port(9092, "kafka"), Port(9644, "admin"))),
|
|
90
|
+
"messaging": CloudService(
|
|
91
|
+
image="nats:2.10-alpine",
|
|
92
|
+
summary="NATS pub/sub messaging. Clients connect on 4222; monitoring on 8222.",
|
|
93
|
+
command=("-m", "8222"),
|
|
94
|
+
ports=(Port(8222, "monitor", web=True), Port(4222, "nats"))),
|
|
95
|
+
|
|
96
|
+
# --- cache & NoSQL ---
|
|
97
|
+
"cache": CloudService(
|
|
98
|
+
image="redis:7-alpine",
|
|
99
|
+
summary="Redis in-memory store. `redis-cli -h <name>` to set/get keys.",
|
|
100
|
+
ports=(Port(6379, "redis"),)),
|
|
101
|
+
"nosql": CloudService(
|
|
102
|
+
image="mongo:7.0",
|
|
103
|
+
summary="MongoDB document database (user/pass gini). Connect with mongosh.",
|
|
104
|
+
env={"MONGO_INITDB_ROOT_USERNAME": "gini", "MONGO_INITDB_ROOT_PASSWORD": "gini"},
|
|
105
|
+
ports=(Port(27017, "mongo"),)),
|
|
106
|
+
|
|
107
|
+
# --- observability ---
|
|
108
|
+
"metrics": CloudService(
|
|
109
|
+
image="prom/prometheus:v2.54.1",
|
|
110
|
+
summary="Prometheus — scrapes and stores metrics; query them in PromQL.",
|
|
111
|
+
ports=(Port(9090, "console", web=True),)),
|
|
112
|
+
"dashboard": CloudService(
|
|
113
|
+
image="grafana/grafana:11.2.2",
|
|
114
|
+
summary="Grafana dashboards — opens straight to a live view (no login in the lab).",
|
|
115
|
+
# No-login teaching setup: anonymous Admin + skip the login form. The home-dashboard
|
|
116
|
+
# path is set by the compiler's observability auto-wiring ONLY once the dashboard is
|
|
117
|
+
# actually provisioned (otherwise Grafana errors on a missing home dashboard).
|
|
118
|
+
# admin/admin still works if you want to edit/save.
|
|
119
|
+
env={"GF_SECURITY_ADMIN_USER": "admin", "GF_SECURITY_ADMIN_PASSWORD": "admin",
|
|
120
|
+
"GF_AUTH_ANONYMOUS_ENABLED": "true", "GF_AUTH_ANONYMOUS_ORG_ROLE": "Admin",
|
|
121
|
+
"GF_AUTH_DISABLE_LOGIN_FORM": "true"},
|
|
122
|
+
ports=(Port(3000, "console", web=True),)),
|
|
123
|
+
"tracing": CloudService(
|
|
124
|
+
image="jaegertracing/all-in-one:1.62.0",
|
|
125
|
+
summary="Jaeger — view distributed request traces across services.",
|
|
126
|
+
ports=(Port(16686, "console", web=True),)),
|
|
127
|
+
|
|
128
|
+
# --- workload & testing ---
|
|
129
|
+
"load_generator": CloudService(
|
|
130
|
+
image="fortio/fortio:1.68.0",
|
|
131
|
+
summary="Fortio load generator. Open the UI to fire HTTP/gRPC load at a target "
|
|
132
|
+
"and watch QPS and latency histograms.",
|
|
133
|
+
command=("server",),
|
|
134
|
+
ports=(Port(8080, "console", web=True, path="/fortio/"),)),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def is_service(type_key: str) -> bool:
|
|
139
|
+
return type_key in CATALOG
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def service_for(type_key: str) -> CloudService | None:
|
|
143
|
+
return CATALOG.get(type_key)
|