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.
Files changed (278) hide show
  1. gini/__init__.py +12 -0
  2. gini/__main__.py +107 -0
  3. gini/_version.py +24 -0
  4. gini/agent/__init__.py +17 -0
  5. gini/agent/agent_gamemaster.py +140 -0
  6. gini/agent/api.py +291 -0
  7. gini/agent/ask.py +123 -0
  8. gini/agent/authoring.py +72 -0
  9. gini/agent/blackboard.py +114 -0
  10. gini/agent/contracts.py +142 -0
  11. gini/agent/domains.py +91 -0
  12. gini/agent/embed.py +123 -0
  13. gini/agent/gamemaster.py +256 -0
  14. gini/agent/kb.py +148 -0
  15. gini/agent/lesson_resolver.py +261 -0
  16. gini/agent/llm/__init__.py +5 -0
  17. gini/agent/llm/backend.py +43 -0
  18. gini/agent/llm/fake.py +25 -0
  19. gini/agent/llm/ollama.py +206 -0
  20. gini/agent/loop.py +258 -0
  21. gini/agent/mcp_server.py +86 -0
  22. gini/agent/meaning.py +225 -0
  23. gini/agent/mission.py +210 -0
  24. gini/agent/mission_controller.py +208 -0
  25. gini/agent/narration.py +116 -0
  26. gini/agent/notifier.py +86 -0
  27. gini/agent/personas.py +79 -0
  28. gini/agent/reasoning.py +172 -0
  29. gini/agent/recall.py +248 -0
  30. gini/agent/session.py +79 -0
  31. gini/agent/teaching_center.py +482 -0
  32. gini/agent/tools/__init__.py +3 -0
  33. gini/agent/tools/registry.py +193 -0
  34. gini/agent/twin/__init__.py +28 -0
  35. gini/agent/twin/authoring.py +71 -0
  36. gini/agent/twin/contracts.py +54 -0
  37. gini/agent/twin/dialectic.py +189 -0
  38. gini/agent/twin/harness.py +93 -0
  39. gini/agent/twin/justify.py +156 -0
  40. gini/agent/twin/learner.py +64 -0
  41. gini/agent/twin/mission.py +60 -0
  42. gini/agent/twin/os_coach.py +79 -0
  43. gini/agent/twin/salience.py +30 -0
  44. gini/agent/understand.py +250 -0
  45. gini/agent/verifiers.py +106 -0
  46. gini/agent/wizard.py +178 -0
  47. gini/agent/xv6_pack.py +74 -0
  48. gini/app/__init__.py +3 -0
  49. gini/app/context.py +368 -0
  50. gini/app/paths.py +121 -0
  51. gini/data/README.md +21 -0
  52. gini/domain/__init__.py +9 -0
  53. gini/domain/assembly.py +209 -0
  54. gini/domain/authoring.py +353 -0
  55. gini/domain/blueprints.py +5 -0
  56. gini/domain/capabilities.py +177 -0
  57. gini/domain/catalog.py +85 -0
  58. gini/domain/certify.py +201 -0
  59. gini/domain/compose.py +413 -0
  60. gini/domain/composition.py +88 -0
  61. gini/domain/concepts.py +383 -0
  62. gini/domain/connection_rules.py +269 -0
  63. gini/domain/constraints.py +153 -0
  64. gini/domain/content.py +59 -0
  65. gini/domain/cpu_journey.py +89 -0
  66. gini/domain/devices.py +747 -0
  67. gini/domain/diagnose.py +201 -0
  68. gini/domain/element_guide.py +327 -0
  69. gini/domain/explain.py +90 -0
  70. gini/domain/fingerprint.py +201 -0
  71. gini/domain/firewall.py +34 -0
  72. gini/domain/flowlog.py +61 -0
  73. gini/domain/flowtable.py +179 -0
  74. gini/domain/fragment_yaml.py +230 -0
  75. gini/domain/fragments.py +169 -0
  76. gini/domain/games/__init__.py +2 -0
  77. gini/domain/games/paging_games.py +119 -0
  78. gini/domain/games/policy_game.py +86 -0
  79. gini/domain/games/process_game.py +48 -0
  80. gini/domain/games/thrash_game.py +75 -0
  81. gini/domain/games/translate_game.py +60 -0
  82. gini/domain/games/trap_game.py +86 -0
  83. gini/domain/grader.py +155 -0
  84. gini/domain/grouping.py +67 -0
  85. gini/domain/legality.py +103 -0
  86. gini/domain/lesson.py +241 -0
  87. gini/domain/lexicon.py +150 -0
  88. gini/domain/machine_state.py +410 -0
  89. gini/domain/missions/networking/basic-lan.yaml +32 -0
  90. gini/domain/missions/networking/cache-in-front.yaml +23 -0
  91. gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
  92. gini/domain/missions/networking/drive-load.yaml +20 -0
  93. gini/domain/missions/networking/fix-the-address.yaml +75 -0
  94. gini/domain/missions/networking/fix-the-lan.yaml +43 -0
  95. gini/domain/missions/networking/inspect-flows.yaml +16 -0
  96. gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
  97. gini/domain/missions/networking/least-privilege.yaml +21 -0
  98. gini/domain/missions/networking/load-balanced-web.yaml +29 -0
  99. gini/domain/missions/networking/observe-it.yaml +24 -0
  100. gini/domain/missions/networking/put-in-vpc.yaml +30 -0
  101. gini/domain/missions/networking/reachability-boundary.yaml +56 -0
  102. gini/domain/missions/networking/sdn-reactive.yaml +35 -0
  103. gini/domain/missions/networking/send-request.yaml +19 -0
  104. gini/domain/missions/networking/serverless-api.yaml +25 -0
  105. gini/domain/missions/networking/service-chain.yaml +33 -0
  106. gini/domain/missions/os/lottery-fix.yaml +19 -0
  107. gini/domain/missions/os/priority-fix.yaml +24 -0
  108. gini/domain/missions.py +111 -0
  109. gini/domain/modulechain.py +36 -0
  110. gini/domain/objectives.py +488 -0
  111. gini/domain/os_zoo.py +79 -0
  112. gini/domain/paging_sim.py +141 -0
  113. gini/domain/pricing.py +199 -0
  114. gini/domain/probes.py +226 -0
  115. gini/domain/profile.py +142 -0
  116. gini/domain/recipes.py +738 -0
  117. gini/domain/riders.py +309 -0
  118. gini/domain/router_modules.py +224 -0
  119. gini/domain/routetable.py +67 -0
  120. gini/domain/scoring.py +76 -0
  121. gini/domain/staging.py +122 -0
  122. gini/domain/syscall_builder.py +144 -0
  123. gini/domain/topic_cloud.py +62 -0
  124. gini/domain/topology.py +213 -0
  125. gini/domain/vocabulary.py +51 -0
  126. gini/domain/xv6.py +808 -0
  127. gini/domain/xv6_fs.py +250 -0
  128. gini/domain/xv6_runner.py +113 -0
  129. gini/domain/xv6_vm.py +385 -0
  130. gini/gloader.py +17 -0
  131. gini/runtime/__init__.py +18 -0
  132. gini/runtime/cloudfabric_agent.py +370 -0
  133. gini/runtime/console.py +68 -0
  134. gini/runtime/control.py +70 -0
  135. gini/runtime/frame.py +138 -0
  136. gini/runtime/gbridge.py +638 -0
  137. gini/runtime/grouter.py +223 -0
  138. gini/runtime/hostsim.py +90 -0
  139. gini/runtime/shuttle.py +348 -0
  140. gini/runtime/switch.py +109 -0
  141. gini/runtime/transport.py +77 -0
  142. gini/runtime/xv6_bridge.py +312 -0
  143. gini/server/__init__.py +22 -0
  144. gini/server/__main__.py +74 -0
  145. gini/server/app.py +140 -0
  146. gini/server/auth.py +82 -0
  147. gini/server/policy.py +57 -0
  148. gini/server/session.py +23 -0
  149. gini/services/__init__.py +15 -0
  150. gini/services/boardflash.py +248 -0
  151. gini/services/boardsetup.py +374 -0
  152. gini/services/cloud_catalog.py +143 -0
  153. gini/services/compiler.py +1858 -0
  154. gini/services/discovery.py +324 -0
  155. gini/services/gloader.py +183 -0
  156. gini/services/orchestrator.py +1460 -0
  157. gini/services/persistence.py +28 -0
  158. gini/services/probe_runner.py +149 -0
  159. gini/services/project.py +217 -0
  160. gini/services/remote.py +93 -0
  161. gini/services/rider_runner.py +96 -0
  162. gini/services/rider_session.py +171 -0
  163. gini/services/shadow_store.py +52 -0
  164. gini/services/terminal.py +45 -0
  165. gini/setup/__init__.py +17 -0
  166. gini/setup/cli.py +109 -0
  167. gini/setup/images.py +33 -0
  168. gini/setup/marker.py +43 -0
  169. gini/setup/runtime.py +69 -0
  170. gini/ui/__init__.py +3 -0
  171. gini/ui/assets/app_icon.icns +0 -0
  172. gini/ui/assets/app_icon.ico +0 -0
  173. gini/ui/assets/app_icon.png +0 -0
  174. gini/ui/assets/app_icon_1024.png +0 -0
  175. gini/ui/assets/cue/_w.txt +1 -0
  176. gini/ui/assets/cue/ai.png +0 -0
  177. gini/ui/assets/cue/canvas.png +0 -0
  178. gini/ui/assets/cue/cloud.png +0 -0
  179. gini/ui/assets/cue/cost.png +0 -0
  180. gini/ui/assets/cue/dark/ai.png +0 -0
  181. gini/ui/assets/cue/dark/canvas.png +0 -0
  182. gini/ui/assets/cue/dark/cloud.png +0 -0
  183. gini/ui/assets/cue/dark/cost.png +0 -0
  184. gini/ui/assets/cue/dark/metrics.png +0 -0
  185. gini/ui/assets/cue/dark/router.png +0 -0
  186. gini/ui/assets/cue/dark/run.png +0 -0
  187. gini/ui/assets/cue/dark/serverless.png +0 -0
  188. gini/ui/assets/cue/dark/settings.png +0 -0
  189. gini/ui/assets/cue/dark/welcome.png +0 -0
  190. gini/ui/assets/cue/dark/wizard.png +0 -0
  191. gini/ui/assets/cue/ginibrand/ai.png +0 -0
  192. gini/ui/assets/cue/ginibrand/canvas.png +0 -0
  193. gini/ui/assets/cue/ginibrand/cloud.png +0 -0
  194. gini/ui/assets/cue/ginibrand/cost.png +0 -0
  195. gini/ui/assets/cue/ginibrand/metrics.png +0 -0
  196. gini/ui/assets/cue/ginibrand/router.png +0 -0
  197. gini/ui/assets/cue/ginibrand/run.png +0 -0
  198. gini/ui/assets/cue/ginibrand/serverless.png +0 -0
  199. gini/ui/assets/cue/ginibrand/settings.png +0 -0
  200. gini/ui/assets/cue/ginibrand/welcome.png +0 -0
  201. gini/ui/assets/cue/ginibrand/wizard.png +0 -0
  202. gini/ui/assets/cue/highcontrast/ai.png +0 -0
  203. gini/ui/assets/cue/highcontrast/canvas.png +0 -0
  204. gini/ui/assets/cue/highcontrast/cloud.png +0 -0
  205. gini/ui/assets/cue/highcontrast/cost.png +0 -0
  206. gini/ui/assets/cue/highcontrast/metrics.png +0 -0
  207. gini/ui/assets/cue/highcontrast/router.png +0 -0
  208. gini/ui/assets/cue/highcontrast/run.png +0 -0
  209. gini/ui/assets/cue/highcontrast/serverless.png +0 -0
  210. gini/ui/assets/cue/highcontrast/settings.png +0 -0
  211. gini/ui/assets/cue/highcontrast/welcome.png +0 -0
  212. gini/ui/assets/cue/highcontrast/wizard.png +0 -0
  213. gini/ui/assets/cue/light/ai.png +0 -0
  214. gini/ui/assets/cue/light/canvas.png +0 -0
  215. gini/ui/assets/cue/light/cloud.png +0 -0
  216. gini/ui/assets/cue/light/cost.png +0 -0
  217. gini/ui/assets/cue/light/metrics.png +0 -0
  218. gini/ui/assets/cue/light/router.png +0 -0
  219. gini/ui/assets/cue/light/run.png +0 -0
  220. gini/ui/assets/cue/light/serverless.png +0 -0
  221. gini/ui/assets/cue/light/settings.png +0 -0
  222. gini/ui/assets/cue/light/welcome.png +0 -0
  223. gini/ui/assets/cue/light/wizard.png +0 -0
  224. gini/ui/assets/cue/metrics.png +0 -0
  225. gini/ui/assets/cue/router.png +0 -0
  226. gini/ui/assets/cue/run.png +0 -0
  227. gini/ui/assets/cue/serverless.png +0 -0
  228. gini/ui/assets/cue/settings.png +0 -0
  229. gini/ui/assets/cue/welcome.png +0 -0
  230. gini/ui/assets/cue/wizard.png +0 -0
  231. gini/ui/assistant.py +2111 -0
  232. gini/ui/author_dialog.py +184 -0
  233. gini/ui/board_dialog.py +247 -0
  234. gini/ui/branding.py +21 -0
  235. gini/ui/canvas.py +2007 -0
  236. gini/ui/chat_panel.py +7 -0
  237. gini/ui/cpu_journey.py +212 -0
  238. gini/ui/cpu_lab.py +306 -0
  239. gini/ui/cue_cards.py +214 -0
  240. gini/ui/dashboard.py +222 -0
  241. gini/ui/diagnose_game.py +336 -0
  242. gini/ui/fingerprint_lab.py +219 -0
  243. gini/ui/flash_dialog.py +244 -0
  244. gini/ui/flow_layout.py +63 -0
  245. gini/ui/fragment_manager.py +1415 -0
  246. gini/ui/game_catalog.py +184 -0
  247. gini/ui/game_renderers.py +340 -0
  248. gini/ui/games_lab.py +90 -0
  249. gini/ui/inspector.py +1055 -0
  250. gini/ui/live_metrics.py +130 -0
  251. gini/ui/machine_lab.py +1412 -0
  252. gini/ui/main_window.py +3153 -0
  253. gini/ui/memory_lab.py +371 -0
  254. gini/ui/mission_panel.py +302 -0
  255. gini/ui/mode_indicator.py +227 -0
  256. gini/ui/palette.py +112 -0
  257. gini/ui/peripherals.py +218 -0
  258. gini/ui/process_tree.py +130 -0
  259. gini/ui/reset_dialog.py +179 -0
  260. gini/ui/router_lab.py +776 -0
  261. gini/ui/run_button.py +183 -0
  262. gini/ui/settings_dialog.py +234 -0
  263. gini/ui/signin_dialog.py +111 -0
  264. gini/ui/storage_lab.py +219 -0
  265. gini/ui/syscall_builder.py +235 -0
  266. gini/ui/syscall_lab.py +152 -0
  267. gini/ui/theme/__init__.py +5 -0
  268. gini/ui/theme/icons.py +145 -0
  269. gini/ui/theme/manager.py +291 -0
  270. gini/ui/theme/tokens.py +194 -0
  271. gini/ui/trap_lab.py +270 -0
  272. gini/ui/worker_host.py +102 -0
  273. gini/ui/zoo_lab.py +112 -0
  274. gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
  275. gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
  276. gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
  277. gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
  278. 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)