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,324 @@
1
+ """mDNS: let a GINI32 board find the lab without being told an address.
2
+
3
+ A board used to be flashed with the laptop's IP. That breaks the moment the laptop
4
+ moves to another network, gets a new DHCP lease, or is swapped for a classmate's —
5
+ which in a teaching lab is constantly. Here the laptop instead *announces itself*, and
6
+ boards ask "who runs GINI?" on the local link.
7
+
8
+ Two names are published:
9
+
10
+ _gini._udp.local the service (browsable; carries host, port and TXT)
11
+ gini.local a plain A record, for a board that only does a name lookup
12
+
13
+ **Why this lives in gBuilder and not in the gbridge container.** Multicast DNS is
14
+ link-local by design: it is sent to 224.0.0.251 with TTL 1. It does not cross Docker's
15
+ bridge, and on macOS the Docker VM adds a second boundary. The only process that is
16
+ genuinely *on* the classroom LAN is gBuilder itself, so the announcement has to come
17
+ from here — which is also convenient, since gBuilder is what knows a topology is running.
18
+
19
+ Implemented on the standard library alone: gBuilder is installed by students, so adding
20
+ a dependency for ~200 lines of well-specified protocol would be a poor trade. Only the
21
+ slice of DNS that mDNS needs is implemented (RFC 1035 wire format, RFC 6762 semantics).
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import socket
26
+ import struct
27
+ import threading
28
+ import time
29
+
30
+ MDNS_ADDR = "224.0.0.251"
31
+ MDNS_PORT = 5353
32
+
33
+ # record types we speak
34
+ T_A = 1
35
+ T_PTR = 12
36
+ T_TXT = 16
37
+ T_SRV = 33
38
+ T_ANY = 255
39
+
40
+ C_IN = 0x0001
41
+ FLUSH = 0x8000 # mDNS cache-flush bit, set on authoritative responses
42
+ QU = 0x8000 # in a question, "unicast reply requested"
43
+
44
+ SERVICE = "_gini._udp.local"
45
+ INSTANCE = "gbuilder._gini._udp.local"
46
+ HOSTNAME = "gini.local"
47
+ TTL = 120
48
+
49
+
50
+ # --------------------------------------------------------------- wire format
51
+
52
+ def encode_name(name: str) -> bytes:
53
+ """Encode a dotted name as length-prefixed labels. No compression (always legal)."""
54
+ out = bytearray()
55
+ for label in name.rstrip(".").split("."):
56
+ raw = label.encode("utf-8")
57
+ if not 0 < len(raw) < 64:
58
+ raise ValueError(f"bad label {label!r}")
59
+ out.append(len(raw))
60
+ out += raw
61
+ out.append(0)
62
+ return bytes(out)
63
+
64
+
65
+ def decode_name(buf: bytes, off: int) -> tuple[str, int]:
66
+ """Decode a name at `off`, following compression pointers.
67
+
68
+ Returns (name, offset just past the name *in the original stream*). Pointer loops
69
+ are bounded rather than trusted — this parses packets from the network.
70
+ """
71
+ labels: list[str] = []
72
+ jumped = False
73
+ end = off
74
+ hops = 0
75
+ while True:
76
+ if off >= len(buf):
77
+ raise ValueError("truncated name")
78
+ ln = buf[off]
79
+ if ln & 0xC0 == 0xC0: # compression pointer
80
+ if off + 1 >= len(buf):
81
+ raise ValueError("truncated pointer")
82
+ ptr = ((ln & 0x3F) << 8) | buf[off + 1]
83
+ if not jumped:
84
+ end = off + 2
85
+ off = ptr
86
+ hops += 1
87
+ if hops > 16:
88
+ raise ValueError("compression loop")
89
+ jumped = True
90
+ continue
91
+ if ln == 0:
92
+ if not jumped:
93
+ end = off + 1
94
+ break
95
+ off += 1
96
+ labels.append(buf[off:off + ln].decode("utf-8", "replace"))
97
+ off += ln
98
+ return ".".join(labels), end
99
+
100
+
101
+ def _rr(name: str, rtype: int, rdata: bytes, ttl: int = TTL, flush: bool = True) -> bytes:
102
+ return (encode_name(name)
103
+ + struct.pack("!HHIH", rtype, C_IN | (FLUSH if flush else 0), ttl, len(rdata))
104
+ + rdata)
105
+
106
+
107
+ def build_response(records: list[bytes], qid: int = 0) -> bytes:
108
+ """An authoritative mDNS response carrying `records` as answers."""
109
+ header = struct.pack("!HHHHHH", qid, 0x8400, 0, len(records), 0, 0)
110
+ return header + b"".join(records)
111
+
112
+
113
+ def parse_questions(data: bytes) -> list[tuple[str, int, bool]]:
114
+ """Return [(name, qtype, unicast_reply_wanted)] from a query packet."""
115
+ if len(data) < 12:
116
+ return []
117
+ qid, flags, qd, an, ns, ar = struct.unpack("!HHHHHH", data[:12])
118
+ if flags & 0x8000: # a response, not a question
119
+ return []
120
+ out: list[tuple[str, int, bool]] = []
121
+ off = 12
122
+ for _ in range(qd):
123
+ try:
124
+ name, off = decode_name(data, off)
125
+ qtype, qclass = struct.unpack("!HH", data[off:off + 4])
126
+ off += 4
127
+ except (ValueError, struct.error):
128
+ break
129
+ out.append((name.lower(), qtype, bool(qclass & QU)))
130
+ return out
131
+
132
+
133
+ def _txt(pairs: dict[str, str]) -> bytes:
134
+ out = bytearray()
135
+ for k, v in pairs.items():
136
+ item = f"{k}={v}".encode("utf-8")[:255]
137
+ out.append(len(item))
138
+ out += item
139
+ return bytes(out) or b"\x00"
140
+
141
+
142
+ # ------------------------------------------------------------------- helpers
143
+
144
+ def lan_address() -> str:
145
+ """This machine's address on the LAN the boards are on.
146
+
147
+ Uses a connected UDP socket, which only asks the kernel to pick a source address for
148
+ a route — no packet is sent and the destination need not exist or be reachable.
149
+ """
150
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
151
+ try:
152
+ s.connect(("8.8.8.8", 80))
153
+ return s.getsockname()[0]
154
+ except OSError:
155
+ return "127.0.0.1"
156
+ finally:
157
+ s.close()
158
+
159
+
160
+ # ----------------------------------------------------------------- responder
161
+
162
+ class GiniAdvertiser:
163
+ """Announces the running GINI lab on the local link so boards can find it.
164
+
165
+ Start it when a topology containing GINI32 boards comes up; stop it on teardown, so
166
+ a board never latches onto a laptop with nothing running.
167
+ """
168
+
169
+ def __init__(self, port: int, address: str | None = None,
170
+ instance: str = INSTANCE, hostname: str = HOSTNAME,
171
+ txt: dict[str, str] | None = None) -> None:
172
+ self.port = int(port)
173
+ self.address = address or lan_address()
174
+ self.instance = instance
175
+ self.hostname = hostname
176
+ self.txt = txt or {}
177
+ self._sock: socket.socket | None = None
178
+ self._thread: threading.Thread | None = None
179
+ self._stop = threading.Event()
180
+ self.answered = 0 # queries responded to (visible in tests/diagnostics)
181
+ self.error = "" # why start() failed, for the Console to relay
182
+
183
+ # -- the records we are authoritative for -- #
184
+
185
+ def records(self) -> list[bytes]:
186
+ addr = socket.inet_aton(self.address)
187
+ srv = struct.pack("!HHH", 0, 0, self.port) + encode_name(self.hostname)
188
+ return [
189
+ _rr(SERVICE, T_PTR, encode_name(self.instance), flush=False),
190
+ _rr(self.instance, T_SRV, srv),
191
+ _rr(self.instance, T_TXT, _txt(self.txt)),
192
+ _rr(self.hostname, T_A, addr),
193
+ ]
194
+
195
+ def _answers_for(self, qname: str, qtype: int) -> list[bytes]:
196
+ """Which of our records answer this question (empty = not ours, stay silent)."""
197
+ addr = socket.inet_aton(self.address)
198
+ srv = struct.pack("!HHH", 0, 0, self.port) + encode_name(self.hostname)
199
+ out: list[bytes] = []
200
+ if qname == SERVICE and qtype in (T_PTR, T_ANY):
201
+ # a browse: answer with the instance, and include what the client will ask
202
+ # for next so one round trip is enough
203
+ out += [_rr(SERVICE, T_PTR, encode_name(self.instance), flush=False),
204
+ _rr(self.instance, T_SRV, srv),
205
+ _rr(self.instance, T_TXT, _txt(self.txt)),
206
+ _rr(self.hostname, T_A, addr)]
207
+ elif qname == self.instance.lower() and qtype in (T_SRV, T_ANY):
208
+ out += [_rr(self.instance, T_SRV, srv), _rr(self.hostname, T_A, addr)]
209
+ elif qname == self.instance.lower() and qtype == T_TXT:
210
+ out.append(_rr(self.instance, T_TXT, _txt(self.txt)))
211
+ elif qname == self.hostname.lower() and qtype in (T_A, T_ANY):
212
+ out.append(_rr(self.hostname, T_A, addr))
213
+ return out
214
+
215
+ def handle_query(self, data: bytes) -> bytes | None:
216
+ """Build the response to a query packet, or None if it asks nothing of ours."""
217
+ answers: list[bytes] = []
218
+ for qname, qtype, _unicast in parse_questions(data):
219
+ answers += self._answers_for(qname, qtype)
220
+ if not answers:
221
+ return None
222
+ self.answered += 1
223
+ return build_response(answers)
224
+
225
+ # -- socket lifecycle -- #
226
+
227
+ def _open(self) -> socket.socket:
228
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
229
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
230
+ # macOS runs its own mDNSResponder on 5353; SO_REUSEPORT lets us coexist.
231
+ if hasattr(socket, "SO_REUSEPORT"):
232
+ try:
233
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
234
+ except OSError:
235
+ pass
236
+ s.bind(("", MDNS_PORT))
237
+ # Pin multicast to the LAN interface rather than letting the kernel choose.
238
+ # A developer laptop running Docker has several interfaces (bridges, vEth,
239
+ # utun/VPN), and the default route for 224.0.0.251 may be none of the one the
240
+ # boards are on — in which case we announce perfectly, to nobody. Both the
241
+ # group membership and the outgoing interface are pinned to self.address.
242
+ local = socket.inet_aton(self.address)
243
+ try:
244
+ s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, local)
245
+ mreq = socket.inet_aton(MDNS_ADDR) + local
246
+ s.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
247
+ except OSError:
248
+ # Loopback-only or an address that is not on a multicast-capable interface:
249
+ # fall back to the default interface rather than refusing to advertise.
250
+ mreq = socket.inet_aton(MDNS_ADDR) + socket.inet_aton("0.0.0.0")
251
+ s.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
252
+ s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1) # link-local only
253
+ s.settimeout(0.5)
254
+ return s
255
+
256
+ def announce(self) -> None:
257
+ """Unsolicited announcement, so boards learn about us without asking."""
258
+ if self._sock is None:
259
+ return
260
+ try:
261
+ self._sock.sendto(build_response(self.records()), (MDNS_ADDR, MDNS_PORT))
262
+ except OSError:
263
+ pass
264
+
265
+ def _serve(self) -> None:
266
+ # RFC 6762 asks for a couple of spaced announcements when a service appears.
267
+ for delay in (0.0, 1.0):
268
+ if self._stop.wait(delay):
269
+ return
270
+ self.announce()
271
+ last_announce = time.time()
272
+ while not self._stop.is_set():
273
+ try:
274
+ data, addr = self._sock.recvfrom(9000)
275
+ except (socket.timeout, OSError):
276
+ data = None
277
+ if data:
278
+ try:
279
+ reply = self.handle_query(data)
280
+ except (ValueError, struct.error):
281
+ reply = None # malformed query: ignore, never crash
282
+ if reply:
283
+ try:
284
+ self._sock.sendto(reply, (MDNS_ADDR, MDNS_PORT))
285
+ except OSError:
286
+ pass
287
+ # re-announce periodically: cheap, and it heals a board that missed the
288
+ # first announcement or whose cache expired.
289
+ if time.time() - last_announce > TTL / 2:
290
+ self.announce()
291
+ last_announce = time.time()
292
+
293
+ def start(self) -> bool:
294
+ if self._thread is not None:
295
+ return True
296
+ try:
297
+ self._sock = self._open()
298
+ except OSError as exc:
299
+ # Not fatal — a board can always be pinned with `set server <ip>`. But it
300
+ # MUST be visible: a silent failure here looks exactly like a board that
301
+ # cannot see the network, and sends you debugging the wrong machine.
302
+ self._sock = None
303
+ self.error = f"{exc.__class__.__name__}: {exc}"
304
+ return False
305
+ self._stop.clear()
306
+ self._thread = threading.Thread(target=self._serve, name="gini-mdns", daemon=True)
307
+ self._thread.start()
308
+ return True
309
+
310
+ def stop(self) -> None:
311
+ self._stop.set()
312
+ if self._thread is not None:
313
+ self._thread.join(timeout=2.0)
314
+ self._thread = None
315
+ if self._sock is not None:
316
+ try:
317
+ self._sock.close()
318
+ except OSError:
319
+ pass
320
+ self._sock = None
321
+
322
+ @property
323
+ def running(self) -> bool:
324
+ return self._thread is not None and self._thread.is_alive()
@@ -0,0 +1,183 @@
1
+ """gLoader -- the GINI topology loader.
2
+
3
+ gBuilder draws a topology and saves it as a ``.gini`` spec; gLoader is the component
4
+ that turns that spec into a *running* network. It does its job in two steps that the
5
+ user never has to see:
6
+
7
+ 1. **Compile** the spec into a concrete runtime plan -- give each broadcast domain a
8
+ subnet, hand every interface an IP/MAC, assign each link a UDP port, and work out
9
+ the routes (this is :class:`~gini.services.compiler.RuntimeCompiler`).
10
+ 2. **Launch** the plan -- a Docker container per end system, a gRouter process per
11
+ router, a switch process per switch, all wired together by Ethernet-over-UDP
12
+ links (this is :class:`~gini.services.orchestrator.Orchestrator`). There is also
13
+ an in-process simulator for a no-Docker quick run.
14
+
15
+ It accepts either a live in-memory :class:`Topology` (what gBuilder hands it when you
16
+ press Run) or a saved ``.gini`` file (handy from the command line).
17
+
18
+ Programmatic::
19
+
20
+ GLoader(runtime_dir).up(topology, workdir) # compile + launch on Docker
21
+ GLoader(runtime_dir).simulate(topology) # compile + run in-process
22
+
23
+ Command line::
24
+
25
+ python -m gini.gloader topology.gini # launch on Docker
26
+ python -m gini.gloader topology.gini --sim # run the in-process simulator
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import tempfile
31
+ from pathlib import Path
32
+
33
+ from ..domain.topology import Topology
34
+ from .compiler import RuntimeCompiler, RuntimeConfig
35
+ from .orchestrator import Orchestrator, Sim, simulate
36
+ from .persistence import load_project
37
+
38
+ Spec = "Topology | RuntimeConfig | str | Path"
39
+
40
+
41
+ class GLoader:
42
+ """Loads a topology spec and brings the network up (compile + launch)."""
43
+
44
+ def __init__(self, runtime_dir: str | Path) -> None:
45
+ self._compiler = RuntimeCompiler()
46
+ self._orch = Orchestrator(runtime_dir)
47
+
48
+ @property
49
+ def orchestrator(self):
50
+ """The live Orchestrator (tracks the running compose project) — the seam behavioral probes
51
+ exec through. It carries the workdir/_dc set by the last `up()`."""
52
+ return self._orch
53
+
54
+ # -- spec handling ------------------------------------------------------ #
55
+ @staticmethod
56
+ def read_spec(path: str | Path) -> Topology:
57
+ """Read a saved ``.gini`` topology spec from disk."""
58
+ return load_project(path)
59
+
60
+ def compile(self, topo: Topology) -> RuntimeConfig:
61
+ """Turn a topology into a concrete runtime plan (the hidden compile step)."""
62
+ return self._compiler.compile(topo)
63
+
64
+ def _as_config(self, spec) -> RuntimeConfig:
65
+ if isinstance(spec, RuntimeConfig):
66
+ return spec
67
+ topo = spec if isinstance(spec, Topology) else self.read_spec(spec)
68
+ return self.compile(topo)
69
+
70
+ # -- launch ------------------------------------------------------------- #
71
+ def up(self, spec, workdir: str | Path | None = None,
72
+ auto_internet: bool = True, laptop_id: str = "") -> tuple[bool, str]:
73
+ """Compile and launch the topology on Docker.
74
+
75
+ ``spec`` may be a live :class:`Topology`, a pre-compiled :class:`RuntimeConfig`,
76
+ or a path to a saved ``.gini`` file. ``auto_internet`` off makes the lab network
77
+ ``internal`` (no outbound internet — faithful mode).
78
+ """
79
+ cfg = self._as_config(spec)
80
+ workdir = workdir or tempfile.mkdtemp(prefix="gini-lab-")
81
+ return self._orch.up(cfg, workdir, auto_internet=auto_internet,
82
+ laptop_id=laptop_id)
83
+
84
+ def redeploy_faas(self, spec, auto_internet: bool = True) -> tuple[bool, str]:
85
+ """Re-deploy only the serverless runtime with the current function code (AWS-style
86
+ 'Deploy') — recreates just the `faas` container, leaving the rest of the lab up."""
87
+ return self._orch.redeploy_faas(self._as_config(spec), auto_internet=auto_internet)
88
+
89
+ def simulate(self, spec) -> Sim:
90
+ """Compile and run the topology in-process (no Docker)."""
91
+ return simulate(self._as_config(spec))
92
+
93
+ def update_cpus(self, service: str, cpus: float) -> tuple[bool, str]:
94
+ """Live-change a running container's CPU cap (vertical scaling), no restart."""
95
+ return self._orch.update_cpus(service, cpus)
96
+
97
+ def stats(self, service: str) -> dict | None:
98
+ """One CPU%/memory sample for a running container (for the Live tab plots)."""
99
+ return self._orch.stats(service)
100
+
101
+ def stats_all(self) -> dict:
102
+ """CPU/mem/net for every running container in one call (per-element Live history)."""
103
+ return self._orch.stats_all()
104
+
105
+ def runtime_available(self, name: str) -> bool:
106
+ """Whether the active Docker backend has an OCI runtime (e.g. 'kata') registered."""
107
+ return self._orch.runtime_available(name)
108
+
109
+ def startup_times(self) -> dict:
110
+ """Per-element startup time in ms (the VM-vs-container headline metric)."""
111
+ return self._orch.startup_times()
112
+
113
+ def k8s_apply(self, service: str) -> tuple[bool, str]:
114
+ """Apply the generated K8s manifests once the k3s cluster is Ready."""
115
+ return self._orch.k8s_apply(service)
116
+
117
+ def k8s_pods(self, service: str) -> list:
118
+ """Current pods in a k3s cluster (for canvas/status read-back)."""
119
+ return self._orch.k8s_pods(service)
120
+
121
+ def k8s_metrics(self, service: str) -> dict:
122
+ """Per-deployment replicas / CPU% / target for the Live view."""
123
+ return self._orch.k8s_metrics(service)
124
+
125
+ def k8s_scale(self, service: str, deployment: str, replicas) -> tuple[bool, str]:
126
+ return self._orch.k8s_scale(service, deployment, replicas)
127
+
128
+ def k8s_set_hpa(self, service: str, hpa: str, target=None, mn=None, mx=None):
129
+ return self._orch.k8s_set_hpa(service, hpa, target, mn, mx)
130
+
131
+ def fabric_metrics(self) -> dict | None:
132
+ """The cloud-fabric agent's normalized app-level metrics for the whole lab."""
133
+ return self._orch.fabric_metrics()
134
+
135
+ def drive_load(self, host_port: int, url: str, qps, conns=8) -> tuple[bool, str]:
136
+ """(Re)start a Fortio load generator at `qps` against `url` — also the throttle."""
137
+ return self._orch.drive_load(host_port, url, qps, conns)
138
+
139
+ def stop_load(self, host_port: int) -> tuple[bool, str]:
140
+ return self._orch.stop_load(host_port)
141
+
142
+ def down(self) -> tuple[bool, str]:
143
+ """Tear the running network down."""
144
+ return self._orch.down()
145
+
146
+ def status(self, workdir: str | Path | None = None) -> dict[str, str]:
147
+ """Per-element run state of the launched network."""
148
+ return self._orch.status(workdir)
149
+
150
+
151
+ def main(argv: list[str] | None = None) -> int:
152
+ import argparse
153
+
154
+ ap = argparse.ArgumentParser(
155
+ prog="gini.gloader",
156
+ description="Load a GINI topology spec (.gini) and bring the network up.")
157
+ ap.add_argument("spec", help="path to a saved .gini topology spec")
158
+ ap.add_argument("--sim", action="store_true",
159
+ help="run the in-process simulator instead of launching Docker")
160
+ args = ap.parse_args(argv)
161
+
162
+ from .. import runtime as _rt
163
+ loader = GLoader(Path(_rt.__file__).parent)
164
+
165
+ if args.sim:
166
+ sim = loader.simulate(args.spec)
167
+ sim.start()
168
+ print(f"gLoader: simulating {args.spec} ({len(sim._nodes)} nodes). "
169
+ f"Press Ctrl-C to stop.")
170
+ try:
171
+ import time
172
+ while True:
173
+ time.sleep(1)
174
+ except KeyboardInterrupt:
175
+ return 0
176
+
177
+ ok, msg = loader.up(args.spec)
178
+ print(msg)
179
+ return 0 if ok else 1
180
+
181
+
182
+ if __name__ == "__main__":
183
+ raise SystemExit(main())