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,1460 @@
1
+ """Orchestrator — bring a compiled RuntimeConfig to life.
2
+
3
+ Two backends, one config:
4
+ * simulate() — run the gini.runtime classes in-process over localhost UDP. No Docker,
5
+ no privileges; used for tests and a quick "does it connect?" check.
6
+ * up()/down() — write a self-contained Docker project and launch it via `docker compose`
7
+ on the user's machine (machines as containers, the fabric as one container).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ import threading
16
+ import time
17
+ from pathlib import Path
18
+
19
+ from ..runtime import HostSim, Router, make_switch
20
+ from .compiler import RuntimeConfig
21
+
22
+ # The real C gRouter runs as its own container from this prebuilt image
23
+ # (built once: `cd backend && docker build -f grouter-build/Dockerfile -t gini-grouter .`).
24
+ # Override with GINI_GROUTER_IMAGE.
25
+ GROUTER_IMAGE = os.environ.get("GINI_GROUTER_IMAGE", "gini-grouter")
26
+ # The POX (Python 3) SDN controller image
27
+ # (built once: `cd backend/sdn && docker build -t gini-pox .`). Override with GINI_POX_IMAGE.
28
+ POX_IMAGE = os.environ.get("GINI_POX_IMAGE", "gini-pox")
29
+
30
+ # The external uplink network for the Internet element (NAT gateway). Fixed subnet so
31
+ # the gateway container can deterministically find its WAN interface + default route.
32
+ # Must match runtime/shuttle.py.
33
+ WAN_NET = "wan"
34
+ WAN_SUBNET = "192.168.244.0/24"
35
+ WAN_GATEWAY = "192.168.244.1"
36
+
37
+ # GINI Cloud Fabric telemetry agent — one container polling every cloud service's native
38
+ # metrics. Published on a fixed host port so gBuilder can poll http://localhost:PORT.
39
+ CLOUDFABRIC_PORT = 9099
40
+ CLOUDFABRIC_HOST_PORT = 39099
41
+ # GINI32: the gbridge relay's UDP port. Boards are flashed with the host address and
42
+ # this port (gbridge_config.h: GB_DEFAULT_SERVER_PORT), so it is deliberately fixed and
43
+ # identical inside and outside the container — a student reading the firmware and a
44
+ # student reading the compose file must see the same number.
45
+ GBRIDGE_PORT = 5555
46
+ GBRIDGE_HOST_PORT = int(os.environ.get("GINI_GBRIDGE_PORT", GBRIDGE_PORT))
47
+ # Read-only status feed for the UI (board online/offline, signal, connected devices).
48
+ GBRIDGE_STATUS_PORT = 39098
49
+
50
+
51
+ def _parse_bytes(s: str) -> float:
52
+ """A docker-stats size string ('1.2kB' / '3.4MB' / '512B') -> bytes (decimal units)."""
53
+ s = s.strip()
54
+ factors = {"GB": 1e9, "MB": 1e6, "kB": 1e3, "KB": 1e3, "B": 1.0,
55
+ "GiB": 2**30, "MiB": 2**20, "KiB": 2**10}
56
+ for unit in sorted(factors, key=len, reverse=True):
57
+ if s.endswith(unit):
58
+ try:
59
+ return float(s[:-len(unit)].strip()) * factors[unit]
60
+ except ValueError:
61
+ return 0.0
62
+ return 0.0
63
+
64
+
65
+ def _parse_mem_mib(s: str) -> float:
66
+ """A docker-stats memory string ('45.2MiB' / '1.1GiB' / '512B') -> MiB."""
67
+ s = s.strip()
68
+ factors = {"GiB": 1024.0, "MiB": 1.0, "KiB": 1 / 1024.0, "B": 1 / 1048576.0,
69
+ "GB": 1000.0 / 1024, "MB": 1000.0 / 1048576 * 1024, "kB": 1 / 1024.0}
70
+ for unit in sorted(factors, key=len, reverse=True): # match 'MiB' before 'B'
71
+ if s.endswith(unit):
72
+ try:
73
+ return float(s[:-len(unit)].strip()) * factors[unit]
74
+ except ValueError:
75
+ return 0.0
76
+ try:
77
+ return float(s) / 1048576.0
78
+ except ValueError:
79
+ return 0.0
80
+
81
+
82
+ class Sim:
83
+ """A running in-process topology."""
84
+
85
+ def __init__(self) -> None:
86
+ self.machines: dict[str, HostSim] = {}
87
+ self._nodes: list = []
88
+
89
+ def start(self) -> None:
90
+ for node in self._nodes:
91
+ threading.Thread(target=node.run, daemon=True).start()
92
+ time.sleep(0.25)
93
+
94
+ def ping(self, src: str, dst_ip: str, timeout: float = 3.0) -> bool:
95
+ host = self.machines[src]
96
+ ident = (hash(src + dst_ip) & 0x7FFF) or 1
97
+ deadline = time.time() + timeout
98
+ seq = 0
99
+ while time.time() < deadline:
100
+ seq += 1
101
+ host.ping(dst_ip, ident, seq)
102
+ end = time.time() + 0.4
103
+ while time.time() < end:
104
+ if any(i == ident for i, _ in host.replies):
105
+ return True
106
+ time.sleep(0.02)
107
+ return False
108
+
109
+
110
+ def simulate(config: RuntimeConfig) -> Sim:
111
+ rt = config.to_runtime(docker=False)
112
+ sim = Sim()
113
+ for m in rt["machines"]:
114
+ h = HostSim(m)
115
+ sim.machines[m["name"]] = h
116
+ sim._nodes.append(h)
117
+ for s in rt["switches"]:
118
+ sim._nodes.append(make_switch(s))
119
+ for r in rt["routers"]:
120
+ sim._nodes.append(Router(r))
121
+ sim.start()
122
+ return sim
123
+
124
+
125
+ # --------------------------------------------------------------------------- #
126
+ # Docker project emission
127
+ # --------------------------------------------------------------------------- #
128
+ # --------------------------------------------------------------------------- #
129
+ # Machine images: two TOOLKITS, and lean is the default.
130
+ #
131
+ # A Host is the most replicated container in GINI — a ten-node topology is ten of them — so its
132
+ # image size is multiplied by ten while a Grafana is multiplied by one. It was also, historically,
133
+ # our FATTEST image: python:3.12-slim plus bind9, postfix, ettercap, tshark, nmap, haproxy… every
134
+ # host carried a mail server and a DNS server it would never run. On a lab machine slimmer than a
135
+ # developer's, that is where "GINI is slow" comes from.
136
+ #
137
+ # So a Machine now picks a toolkit:
138
+ # lean (DEFAULT) — Alpine + python3 + the tools a student actually types: ip, ping, traceroute,
139
+ # tcpdump, curl, dig, nc, socat, iperf3, nmap. ~10x smaller.
140
+ # full — the old Debian image, for the book experiments that genuinely need the heavy
141
+ # services (DNS with bind9, mail with postfix, ARP/DNS spoofing with ettercap).
142
+ # Those tools are Debian-shaped; this is not worth porting to musl.
143
+ #
144
+ # NOTE this is a different axis from the element's SIZE (S/M/L/XL), which sets the CPU cap and the
145
+ # cost multiplier. A lean host with an XL CPU cap is a perfectly sensible thing to want, so the two
146
+ # must not be conflated: size = how much it gets, toolkit = what's installed in it.
147
+ # --------------------------------------------------------------------------- #
148
+ MACHINE_LEAN, MACHINE_FULL, MACHINE_SECURITY = "lean", "full", "security"
149
+ MACHINE_GUI = "gui" # a HEADFUL machine: lean tools + a light X desktop
150
+ MACHINE_TOOLKIT_DEFAULT = MACHINE_LEAN
151
+
152
+ # Alpine package names (musl). Deliberately NOT: bind9, postfix, ettercap, haproxy, dsniff, lynx,
153
+ # telnetd — those are what made the old image huge, and they belong to the `full` toolkit.
154
+ _MACHINE_TOOLS_LEAN = (
155
+ "python3 iproute2 iputils busybox-extras tcpdump curl wget bind-tools "
156
+ "netcat-openbsd socat iperf3 nmap ethtool traceroute mtr bridge-utils iptables"
157
+ )
158
+
159
+ _DOCKERFILE_MACHINE_LEAN = f"""FROM alpine:3.20
160
+ RUN apk add --no-cache {_MACHINE_TOOLS_LEAN}
161
+ WORKDIR /app
162
+ COPY dataplane/ /app/dataplane/
163
+ CMD ["python3", "-m", "dataplane.shuttle"]
164
+ """
165
+
166
+ # The FULL image ships "batteries included" — the heavy services the GINI book experiments stand
167
+ # up — so a student running those chapters never needs to `apt install` inside a container.
168
+ MACHINE_BASE = "Debian (python:3.12-slim)"
169
+ _MACHINE_TOOLS = (
170
+ "iproute2 net-tools iputils-ping iputils-tracepath iputils-arping traceroute "
171
+ "mtr-tiny dnsutils netcat-openbsd socat curl wget nmap tcpdump tshark iperf3 "
172
+ "ethtool bridge-utils telnet telnetd hping3 iptables procps nano less ca-certificates "
173
+ # services + tools the GINI book experiments stand up, so a topology runs them offline
174
+ # (no in-container apt). DNS: bind9 (named). Mail: postfix + mailutils (the `mail` MUA).
175
+ # Web caching: squid (forward proxy). Load balancing: haproxy. Security: dsniff
176
+ # (arpspoof/dnsspoof), ettercap, lynx.
177
+ # DHCP: isc-dhcp-client (dhclient) to exercise the gRouter's control-plane DHCP server.
178
+ "bind9 postfix mailutils squid haproxy dsniff ettercap-text-only lynx isc-dhcp-client"
179
+ )
180
+ # The SECURITY tier = the FULL toolkit PLUS the heavy security engines the Security part (Part VI)
181
+ # stands up: openssl (TLS/PKI), wireguard-tools (encrypted tunnels), isc-dhcp-server (rogue-DHCP
182
+ # lab), suricata (IDS), tcpreplay (replay captures into the IDS). Opt-in per host, so ordinary hosts
183
+ # never carry it. WireGuard needs the in-kernel module (present in modern Docker Desktop kernels) or
184
+ # a userspace fallback; the VPN lab notes this.
185
+ _MACHINE_TOOLS_SECURITY = _MACHINE_TOOLS + (
186
+ " openssl wireguard-tools isc-dhcp-server suricata tcpreplay"
187
+ )
188
+ # human-readable lists for the inspector / GINI (the commands students actually type)
189
+ MACHINE_TOOLS_LEAN_HUMAN = ("ip, ifconfig, ping, traceroute, mtr, arping, dig/nslookup/host, "
190
+ "tcpdump, nmap, nc, socat, curl, wget, iperf3, ethtool, brctl, "
191
+ "iptables")
192
+ MACHINE_TOOLS_HUMAN = ("ip, ifconfig, ping, traceroute, mtr, tracepath, arping, "
193
+ "dig/nslookup/host, tcpdump, tshark, nmap, nc, socat, curl, wget, "
194
+ "iperf3, ethtool, brctl, telnet/telnetd, hping3, iptables; "
195
+ "plus experiment servers: named (bind9), postfix+mail, squid (web cache), "
196
+ "haproxy, arpspoof/dnsspoof (dsniff), ettercap, lynx, dhclient (isc-dhcp-client)")
197
+ MACHINE_TOOLS_SECURITY_HUMAN = (MACHINE_TOOLS_HUMAN +
198
+ "; plus security engines: openssl (TLS/PKI), wg (WireGuard VPN), "
199
+ "suricata (IDS), isc-dhcp-server, tcpreplay")
200
+
201
+
202
+ # The GUI toolkit = the lean Alpine host PLUS a light X desktop, served to gBuilder over noVNC:
203
+ # Xvfb (a virtual screen), fluxbox (window manager), PCManFM (desktop + file manager), an xterm,
204
+ # and Dillo (a tiny browser to hit web servers in the topology). x11vnc + noVNC publish the screen.
205
+ # It's a REAL fabric host (runs dataplane.shuttle), so it also has the lean networking tools.
206
+ MACHINE_TOOLS_GUI_HUMAN = (MACHINE_TOOLS_LEAN_HUMAN +
207
+ "; plus a graphical desktop (fluxbox WM, PCManFM file manager, xterm, "
208
+ "Dillo browser) opened over noVNC")
209
+
210
+
211
+ def machine_tools(toolkit: str) -> str:
212
+ if toolkit == MACHINE_SECURITY:
213
+ return MACHINE_TOOLS_SECURITY_HUMAN
214
+ if toolkit == MACHINE_GUI:
215
+ return MACHINE_TOOLS_GUI_HUMAN
216
+ return MACHINE_TOOLS_HUMAN if toolkit == MACHINE_FULL else MACHINE_TOOLS_LEAN_HUMAN
217
+
218
+
219
+ _DOCKERFILE_MACHINE = f"""FROM python:3.12-slim
220
+ ENV DEBIAN_FRONTEND=noninteractive
221
+ RUN echo "wireshark-common wireshark-common/install-setuid boolean true" \\
222
+ | debconf-set-selections \\
223
+ && apt-get update && apt-get install -y --no-install-recommends \\
224
+ {_MACHINE_TOOLS} \\
225
+ && rm -rf /var/lib/apt/lists/*
226
+ WORKDIR /app
227
+ COPY dataplane/ /app/dataplane/
228
+ CMD ["python", "-m", "dataplane.shuttle"]
229
+ """
230
+
231
+ _DOCKERFILE_MACHINE_SECURITY = f"""FROM python:3.12-slim
232
+ ENV DEBIAN_FRONTEND=noninteractive
233
+ RUN echo "wireshark-common wireshark-common/install-setuid boolean true" \\
234
+ | debconf-set-selections \\
235
+ && apt-get update && apt-get install -y --no-install-recommends \\
236
+ {_MACHINE_TOOLS_SECURITY} \\
237
+ && rm -rf /var/lib/apt/lists/*
238
+ WORKDIR /app
239
+ COPY dataplane/ /app/dataplane/
240
+ CMD ["python", "-m", "dataplane.shuttle"]
241
+ """
242
+
243
+ # The HEADFUL machine: lean Alpine host + a light X desktop, served over noVNC on :6080. The
244
+ # entrypoint starts the desktop stack in the background, then execs dataplane.shuttle in the
245
+ # foreground — so the container is a real fabric node (networking) that also has a GUI.
246
+ _GUI_START = (
247
+ "Xvfb :0 -screen 0 1280x800x24 -nolisten tcp >/dev/null 2>&1 & sleep 1; "
248
+ "fluxbox >/dev/null 2>&1 & pcmanfm --desktop >/dev/null 2>&1 & "
249
+ "x11vnc -display :0 -forever -shared -nopw -rfbport 5900 -bg -quiet -noxdamage >/dev/null 2>&1; "
250
+ "websockify --web=/usr/share/novnc 6080 localhost:5900 >/dev/null 2>&1 & "
251
+ "exec python3 -m dataplane.shuttle"
252
+ )
253
+ _DOCKERFILE_MACHINE_GUI = f"""FROM alpine:3.20
254
+ RUN apk add --no-cache {_MACHINE_TOOLS_LEAN} \\
255
+ xvfb x11vnc fluxbox xterm pcmanfm dillo novnc websockify font-dejavu
256
+ # Ready-made desktop shortcuts so students see a Terminal and Dillo on the desktop at startup
257
+ # (pcmanfm --desktop shows ~/Desktop/*.desktop as clickable icons; +x avoids a trust prompt).
258
+ RUN mkdir -p /root/Desktop \\
259
+ && printf '[Desktop Entry]\\nType=Application\\nName=Terminal\\nExec=xterm\\nTerminal=false\\n' > /root/Desktop/Terminal.desktop \\
260
+ && printf '[Desktop Entry]\\nType=Application\\nName=Dillo Browser\\nExec=dillo\\nTerminal=false\\n' > /root/Desktop/Dillo.desktop \\
261
+ && chmod +x /root/Desktop/*.desktop
262
+ ENV DISPLAY=:0 HOME=/root
263
+ WORKDIR /app
264
+ COPY dataplane/ /app/dataplane/
265
+ CMD ["sh", "-c", "{_GUI_START}"]
266
+ """
267
+
268
+ # toolkit -> (compose image name, dockerfile path). Explicit image names so N hosts of one tier
269
+ # resolve to ONE image and compose builds it once.
270
+ _MACHINE_IMAGE = {
271
+ MACHINE_LEAN: ("gini-machine-lean", "docker/Dockerfile.machine-lean"),
272
+ MACHINE_FULL: ("gini-machine-full", "docker/Dockerfile.machine"),
273
+ MACHINE_SECURITY: ("gini-machine-security", "docker/Dockerfile.machine-security"),
274
+ MACHINE_GUI: ("gini-machine-gui", "docker/Dockerfile.machine-gui"),
275
+ }
276
+
277
+ _DOCKERFILE_FABRIC = """FROM python:3.12-slim
278
+ WORKDIR /app
279
+ COPY dataplane/ /app/dataplane/
280
+ COPY run_fabric.py /app/run_fabric.py
281
+ CMD ["python", "/app/run_fabric.py"]
282
+ """
283
+
284
+ # the GINI Cloud Fabric telemetry agent image (psycopg2 for the Postgres adapter; the
285
+ # rest is stdlib). Same `dataplane/` copy as the machine image, so the agent ships with it.
286
+ _DOCKERFILE_CLOUDFABRIC = """FROM python:3.12-slim
287
+ RUN pip install --no-cache-dir psycopg2-binary
288
+ WORKDIR /app
289
+ COPY dataplane/ /app/dataplane/
290
+ CMD ["python", "-m", "dataplane.cloudfabric_agent"]
291
+ """
292
+
293
+ _DOCKERFILE_SG = """FROM alpine:3.20
294
+ RUN apk add --no-cache iptables
295
+ """
296
+
297
+ _DOCKERFILE_FAAS = """FROM python:3.12-slim
298
+ # event-trigger clients: RabbitMQ (queue), NATS (pub/sub), Kafka/Redpanda (stream).
299
+ # kafka-python-ng is the maintained fork that supports Python 3.12.
300
+ RUN pip install --no-cache-dir pika nats-py kafka-python-ng
301
+ WORKDIR /app
302
+ COPY run_faas.py /app/run_faas.py
303
+ CMD ["python", "/app/run_faas.py"]
304
+ """
305
+
306
+ # The GINI serverless runtime: ONE process hosts every Function as a handler (multiplexed,
307
+ # like real FaaS). Reachable at http://faas:8000/<name>; meters each invocation. Stdlib only.
308
+ _RUN_FAAS = '''"""GINI serverless runtime — hosts every Function as a handler in one process.
309
+
310
+ A Function node is NOT its own container; it is a handler registered here and reachable
311
+ at http://faas:8000/<name>. This mirrors real FaaS: no servers to run, the platform
312
+ multiplexes your functions, runs them on demand, and meters each invocation. Config
313
+ arrives as FAAS_CONFIG (JSON): {"functions": [{"name", "handler", "code"}]}.
314
+ """
315
+ import inspect, json, os, random, threading, time, uuid
316
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
317
+
318
+ CFG = json.loads(os.environ.get("FAAS_CONFIG", "{}"))
319
+ FUNCS = {f["name"]: f for f in CFG.get("functions", [])}
320
+ STATS = {n: {"invocations": 0, "errors": 0, "events": 0, "last_ms": 0.0,
321
+ "cold": True, "count": 0} for n in FUNCS}
322
+ LOCK = threading.Lock()
323
+
324
+
325
+ class Context:
326
+ """The invocation context passed to a handler — the GINI analogue of AWS Lambda's
327
+ `context`: who is running, a unique id per call, and how long is left."""
328
+ def __init__(self, name):
329
+ self.function_name = name
330
+ self.invocation_id = uuid.uuid4().hex
331
+ self.aws_request_id = self.invocation_id # alias for AWS-style code
332
+ self.remaining_ms = 30000
333
+
334
+ def get_remaining_time_in_millis(self):
335
+ return self.remaining_ms
336
+
337
+
338
+ def _compile_custom(code):
339
+ """Compile a student's `def handle(event, context)` (one arg also accepted) into a
340
+ normalized 2-arg callable, or None if the code doesn't define a callable `handle`."""
341
+ ns = {}
342
+ try:
343
+ exec(code, ns)
344
+ except Exception:
345
+ return None
346
+ fn = ns.get("handle")
347
+ if not callable(fn):
348
+ return None
349
+ try:
350
+ nparams = len(inspect.signature(fn).parameters)
351
+ except (TypeError, ValueError):
352
+ nparams = 2
353
+ return fn if nparams >= 2 else (lambda event, context, _f=fn: _f(event))
354
+
355
+
356
+ CUSTOM = {n: _compile_custom(f.get("code", "")) for n, f in FUNCS.items()
357
+ if f.get("handler") == "custom"}
358
+
359
+
360
+ def run_handler(name, event, context):
361
+ f = FUNCS[name]
362
+ h = f.get("handler", "echo")
363
+ cold = STATS[name]["cold"]
364
+ if cold and h != "slow":
365
+ time.sleep(0.25) # cold start: the first call pays init latency
366
+ if h == "slow":
367
+ time.sleep(1.5 if cold else 0.05) # an extra-slow function: always sleeps
368
+ return 200, {"function": name, "cold": cold}
369
+ if h == "fail":
370
+ if random.random() < 0.3:
371
+ raise RuntimeError("simulated failure (shows retries/error handling)")
372
+ return 200, {"function": name, "ok": True}
373
+ if h == "transform":
374
+ body = event.get("body", "")
375
+ return 200, {"function": name, "input": body, "output": body.upper()}
376
+ if h == "counter":
377
+ with LOCK:
378
+ STATS[name]["count"] += 1
379
+ c = STATS[name]["count"]
380
+ return 200, {"function": name, "count": c,
381
+ "note": "resets when the runtime restarts (functions are stateless)"}
382
+ if h == "custom":
383
+ fn = CUSTOM.get(name)
384
+ if fn is None:
385
+ return 500, {"error": "custom handler did not define handle(event, context)"}
386
+ result = fn(event, context)
387
+ # AWS proxy-style: a {statusCode, body} return sets the HTTP status + raw body.
388
+ if isinstance(result, dict) and "statusCode" in result:
389
+ return int(result["statusCode"]), result.get("body", "")
390
+ return 200, {"function": name, "result": result}
391
+ return 200, {"function": name, "method": event.get("method"),
392
+ "path": event.get("path"), "body": event.get("body")} # echo (default)
393
+
394
+
395
+ def invoke(name, event):
396
+ """Run a function and meter it. Both HTTP requests and event triggers go through
397
+ here, so a queue/stream message counts as a real invocation just like an HTTP call."""
398
+ context = Context(name)
399
+ t0 = time.time()
400
+ try:
401
+ code, result = run_handler(name, event, context)
402
+ except Exception as e:
403
+ code, result = 500, {"error": str(e)}
404
+ ms = (time.time() - t0) * 1000.0
405
+ with LOCK:
406
+ s = STATS[name]
407
+ s["invocations"] += 1
408
+ s["last_ms"] = round(ms, 1)
409
+ s["cold"] = False
410
+ if event.get("source") and event.get("source") != "http":
411
+ s["events"] += 1
412
+ if code >= 500:
413
+ s["errors"] += 1
414
+ return code, result
415
+
416
+
417
+ class Handler(BaseHTTPRequestHandler):
418
+ def log_message(self, *a):
419
+ pass
420
+
421
+ def _send(self, code, obj, ctype=None):
422
+ # bytes -> raw; str -> text (a handler's {statusCode, body} returns a string body);
423
+ # dict/list -> JSON.
424
+ if isinstance(obj, bytes):
425
+ body, ctype = obj, ctype or "application/json"
426
+ elif isinstance(obj, str):
427
+ body, ctype = obj.encode(), ctype or "text/plain"
428
+ else:
429
+ body, ctype = json.dumps(obj).encode(), ctype or "application/json"
430
+ self.send_response(code)
431
+ self.send_header("Content-Type", ctype)
432
+ self.send_header("Content-Length", str(len(body)))
433
+ self.end_headers()
434
+ self.wfile.write(body)
435
+
436
+ def _dispatch(self, method):
437
+ path = self.path.split("?", 1)[0]
438
+ if path in ("/_gini/health", "/healthz"):
439
+ return self._send(200, {"ok": True})
440
+ if path == "/_gini/metrics":
441
+ with LOCK:
442
+ snap = {n: {"invocations": s["invocations"], "errors": s["errors"],
443
+ "events": s["events"], "last_ms": s["last_ms"]}
444
+ for n, s in STATS.items()}
445
+ return self._send(200, {"functions": snap,
446
+ "total": sum(s["invocations"] for s in STATS.values())})
447
+ if path in ("/", "/_gini/console"):
448
+ rows = "".join("<li><b>%s</b> [%s] - %d calls</li>"
449
+ % (n, FUNCS[n].get("handler", "echo"), STATS[n]["invocations"])
450
+ for n in FUNCS)
451
+ html = ("<h2>GINI Functions</h2><ul>" + (rows or "<i>none</i>") + "</ul>").encode()
452
+ return self._send(200, html, "text/html")
453
+ fn = path.strip("/").split("/", 1)[0]
454
+ if fn not in FUNCS:
455
+ return self._send(404, {"error": "no such function", "name": fn})
456
+ n = int(self.headers.get("Content-Length", 0) or 0)
457
+ body = self.rfile.read(n).decode("utf-8", "replace") if n else ""
458
+ from urllib.parse import urlsplit, parse_qs
459
+ parts = urlsplit(self.path)
460
+ query = {k: v[0] for k, v in parse_qs(parts.query).items()}
461
+ event = {"method": method, "path": self.path, "rawPath": parts.path,
462
+ "query": query, "headers": dict(self.headers.items()),
463
+ "body": body, "source": "http"}
464
+ code, result = invoke(fn, event)
465
+ self._send(code, result)
466
+
467
+ def do_GET(self):
468
+ self._dispatch("GET")
469
+
470
+ def do_POST(self):
471
+ self._dispatch("POST")
472
+
473
+
474
+ # --- event triggers: subscribe to each Function's sources and invoke per message ---
475
+ # The queue/topic/subject is named after the function, so "publish to <fn>" invokes it.
476
+ # Each subscriber runs in its own thread with a reconnect loop (the broker may start late);
477
+ # the client import lives inside so a missing client only disables that one trigger type.
478
+ def _sub_queue(name, host, port):
479
+ """Message Queue (RabbitMQ / AMQP): consume the queue named after the function."""
480
+ import pika
481
+ creds = pika.PlainCredentials("guest", "guest")
482
+ while True:
483
+ try:
484
+ conn = pika.BlockingConnection(pika.ConnectionParameters(
485
+ host=host, port=port, credentials=creds, heartbeat=30,
486
+ connection_attempts=1, socket_timeout=5))
487
+ ch = conn.channel()
488
+ ch.queue_declare(queue=name, durable=False)
489
+ print("[faas] queue trigger ready:", name, "@", host, flush=True)
490
+ for method, _props, body in ch.consume(name, inactivity_timeout=1):
491
+ if body is None:
492
+ continue
493
+ invoke(name, {"method": "EVENT", "source": "queue",
494
+ "body": body.decode("utf-8", "replace")})
495
+ ch.basic_ack(method.delivery_tag)
496
+ except Exception as e:
497
+ print("[faas] queue trigger retry:", name, e, flush=True)
498
+ time.sleep(3)
499
+
500
+
501
+ def _sub_stream(name, host, port):
502
+ """Event Stream (Redpanda / Kafka API): consume the topic named after the function."""
503
+ from kafka import KafkaConsumer
504
+ while True:
505
+ try:
506
+ c = KafkaConsumer(name, bootstrap_servers="%s:%d" % (host, port),
507
+ auto_offset_reset="latest", consumer_timeout_ms=1000,
508
+ group_id="gini-faas-" + name)
509
+ print("[faas] stream trigger ready:", name, "@", host, flush=True)
510
+ for msg in c:
511
+ invoke(name, {"method": "EVENT", "source": "stream",
512
+ "body": (msg.value or b"").decode("utf-8", "replace")})
513
+ except Exception as e:
514
+ print("[faas] stream trigger retry:", name, e, flush=True)
515
+ time.sleep(3)
516
+
517
+
518
+ def _sub_pubsub(name, host, port):
519
+ """Pub/Sub (NATS): subscribe to the subject named after the function."""
520
+ import asyncio
521
+ import nats
522
+ async def run():
523
+ nc = await nats.connect("nats://%s:%d" % (host, port))
524
+ print("[faas] pubsub trigger ready:", name, "@", host, flush=True)
525
+ async def cb(m):
526
+ invoke(name, {"method": "EVENT", "source": "pubsub",
527
+ "body": m.data.decode("utf-8", "replace")})
528
+ await nc.subscribe(name, cb=cb)
529
+ while True:
530
+ await asyncio.sleep(3600)
531
+ while True:
532
+ try:
533
+ asyncio.run(run())
534
+ except Exception as e:
535
+ print("[faas] pubsub trigger retry:", name, e, flush=True)
536
+ time.sleep(3)
537
+
538
+
539
+ _SUBS = {"queue": _sub_queue, "stream": _sub_stream, "messaging": _sub_pubsub}
540
+
541
+
542
+ def start_triggers():
543
+ """Spawn a subscriber thread for every event trigger declared on a function."""
544
+ for name, f in FUNCS.items():
545
+ for trig in f.get("triggers", []):
546
+ sub = _SUBS.get(trig.get("type"))
547
+ if not sub:
548
+ continue
549
+ threading.Thread(target=sub, name="trig-%s-%s" % (trig["type"], name),
550
+ args=(name, trig["host"], int(trig["port"])),
551
+ daemon=True).start()
552
+
553
+
554
+ if __name__ == "__main__":
555
+ print("[faas] hosting", len(FUNCS), "function(s):", ", ".join(FUNCS), flush=True)
556
+ start_triggers()
557
+ ThreadingHTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
558
+ '''
559
+
560
+ _RUN_FABRIC = '''"""Fabric supervisor: spawn each L2 switch as its own process.
561
+
562
+ Routers are NOT here anymore — each router runs as its own `gini-grouter` container
563
+ (the real C gRouter). The fabric container is now just the switched L2 substrate.
564
+ """
565
+ import json, os, subprocess, sys, time
566
+ PY = sys.executable
567
+ os.environ.setdefault("GINI_CTRL_DIR", "/run/gini") # per-element control sockets
568
+ os.makedirs(os.environ["GINI_CTRL_DIR"], exist_ok=True)
569
+ cfg = json.loads(os.environ["FABRIC_CONFIG"])
570
+ procs = {}
571
+ def spawn(module, conf, env_key):
572
+ env = dict(os.environ); env[env_key] = json.dumps(conf)
573
+ return subprocess.Popen([PY, "-m", module], env=env, cwd="/app")
574
+ for s in cfg.get("switches", []):
575
+ procs[("switch", s["name"])] = (spawn("dataplane.switch", s, "SWITCH_CONFIG"),
576
+ "dataplane.switch", s, "SWITCH_CONFIG")
577
+ print("[fabric] up:", len(cfg.get("switches", [])), "switches", file=sys.stderr)
578
+ while True:
579
+ time.sleep(1)
580
+ for k, (p, mod, conf, key) in list(procs.items()):
581
+ if p.poll() is not None:
582
+ print("[fabric] restarting", k, file=sys.stderr)
583
+ procs[k] = (spawn(mod, conf, key), mod, conf, key)
584
+ '''
585
+
586
+
587
+ def write_project(config: RuntimeConfig, workdir: str | Path, runtime_dir: str | Path,
588
+ auto_internet: bool = True, laptop_id: str = "") -> Path:
589
+ """Write a self-contained Docker project that runs this topology."""
590
+ from ..app.paths import captures_dir, scripts_dir
591
+ captures_dir().mkdir(parents=True, exist_ok=True) # host dir bind-mounted for tap .pcaps
592
+ scripts_dir().mkdir(parents=True, exist_ok=True) # host dir bind-mounted for Lua VNFs
593
+ work = Path(workdir)
594
+ (work / "dataplane").mkdir(parents=True, exist_ok=True)
595
+ (work / "docker").mkdir(exist_ok=True)
596
+ # copy the runtime data-plane modules
597
+ for py in Path(runtime_dir).glob("*.py"):
598
+ shutil.copy(py, work / "dataplane" / py.name)
599
+ (work / "docker" / "Dockerfile.machine").write_text(_DOCKERFILE_MACHINE)
600
+ (work / "docker" / "Dockerfile.machine-lean").write_text(_DOCKERFILE_MACHINE_LEAN)
601
+ (work / "docker" / "Dockerfile.machine-security").write_text(_DOCKERFILE_MACHINE_SECURITY)
602
+ (work / "docker" / "Dockerfile.machine-gui").write_text(_DOCKERFILE_MACHINE_GUI)
603
+ (work / "docker" / "Dockerfile.fabric").write_text(_DOCKERFILE_FABRIC)
604
+ (work / "docker" / "Dockerfile.cloudfabric").write_text(_DOCKERFILE_CLOUDFABRIC)
605
+ (work / "docker" / "Dockerfile.faas").write_text(_DOCKERFILE_FAAS)
606
+ (work / "docker" / "Dockerfile.sg").write_text(_DOCKERFILE_SG)
607
+ (work / "run_fabric.py").write_text(_RUN_FABRIC)
608
+ (work / "run_faas.py").write_text(_RUN_FAAS)
609
+ # security-group iptables scripts, bind-mounted into each member's firewall sidecar
610
+ for fw in config.firewalls:
611
+ d = work / "sg"
612
+ d.mkdir(exist_ok=True)
613
+ (d / f"{fw['member']}.sh").write_text(fw["script"])
614
+ # generated service config (e.g. observability: prometheus.yml, grafana provisioning)
615
+ for s in config.services:
616
+ for rel, content in s.files.items():
617
+ dst = work / rel
618
+ dst.parent.mkdir(parents=True, exist_ok=True)
619
+ dst.write_text(content)
620
+ # kubernetes manifests, bind-mounted into each k3s cluster container for kubectl apply
621
+ for k in config.k8s:
622
+ (work / "k8s" / k.svc).mkdir(parents=True, exist_ok=True)
623
+ (work / "k8s" / k.svc / "manifests.yaml").write_text(k.manifests or "")
624
+ (work / "docker-compose.yml").write_text(
625
+ _compose(config, auto_internet, laptop_id))
626
+ return work
627
+
628
+
629
+ def _compose(config: RuntimeConfig, auto_internet: bool = True,
630
+ laptop_id: str = "") -> str:
631
+ from ..app.paths import captures_dir, scripts_dir
632
+ cap_host = str(captures_dir()) # host path bind-mounted into routers at /captures
633
+ scr_host = str(scripts_dir()) # student Lua modules, mounted read-only at /scripts
634
+ rt = config.to_runtime(docker=True)
635
+ # The `gini` bridge is a normal (non-internal) network so the HOST can reach
636
+ # published web consoles (Grafana/MinIO/…). "Faithful mode" (auto_internet off) does
637
+ # NOT isolate the network — that would also block published ports. Instead each host's
638
+ # shuttle drops its docker-eth0 default route (see `cut_default` below), so a Machine
639
+ # has no path to the internet unless one is routed through a drawn Internet element.
640
+ net = [" gini:", " driver: bridge"]
641
+ # if the canvas has an Internet element (a NAT gateway machine), add an external
642
+ # `wan` bridge with a fixed subnet. Only the gateway joins it; that container NATs
643
+ # the fabric out to the world. This is the lab's egress in faithful mode.
644
+ gw_machines = [m for m in rt["machines"] if m.get("gateway")]
645
+ if gw_machines:
646
+ net += [f" {WAN_NET}:", " driver: bridge",
647
+ " ipam:", " config:",
648
+ f" - subnet: {WAN_SUBNET}", f" gateway: {WAN_GATEWAY}"]
649
+ # VPC/subnet networks. A VPC's shared net is `internal` (the implicit VPC fabric — no
650
+ # internet of its own); the per-VPC `_egress` net is a normal bridge that public-subnet
651
+ # members also join for real internet + host-published consoles. Elements with no VPC
652
+ # stay on the flat `gini` bridge above.
653
+ vpc_nets = rt.get("networks", [])
654
+ for vnet in vpc_nets:
655
+ net += [f" {vnet['name']}:", " driver: bridge"]
656
+ if vnet.get("internal"):
657
+ net.append(" internal: true")
658
+ if vnet.get("cidr"):
659
+ net += [" ipam:", " config:", f" - subnet: {vnet['cidr']}"]
660
+ lines = ["name: gini-lab", "networks:", *net, "services:"]
661
+
662
+ # fabric = the L2 switch substrate only (skip entirely if there are no switches)
663
+ if rt["switches"]:
664
+ fabric = {"switches": rt["switches"]}
665
+ lines += [
666
+ " fabric:",
667
+ " build: { context: ., dockerfile: docker/Dockerfile.fabric }",
668
+ " networks: [gini]",
669
+ " environment:",
670
+ f" FABRIC_CONFIG: '{json.dumps(fabric)}'",
671
+ ]
672
+
673
+ # gbridge = the one container that talks to REAL hardware. It holds the fabric end of
674
+ # every drawn GINI32 board's link and relays frames to the board over the physical LAN.
675
+ # This is the only service with a published port: boards send to the host's LAN address,
676
+ # which is what lets a $5 chip on the desk reach a topology running inside Docker (and
677
+ # is why this works on macOS, where containers cannot see the LAN directly).
678
+ if rt.get("gbridge"):
679
+ gcfg = {"name": "gbridge", "listen_port": GBRIDGE_PORT,
680
+ "status_port": GBRIDGE_STATUS_PORT,
681
+ # Who this install is to a board. A board that has been claimed by a
682
+ # different laptop ignores us, and we ignore it.
683
+ "laptop_id": laptop_id or "", "boards": rt["gbridge"]}
684
+ lines += [
685
+ " gbridge:",
686
+ " build: { context: ., dockerfile: docker/Dockerfile.fabric }",
687
+ " command: [\"python\", \"-m\", \"dataplane.gbridge\"]",
688
+ " networks: [gini]",
689
+ " ports:",
690
+ f' - "{GBRIDGE_HOST_PORT}:{GBRIDGE_PORT}/udp"',
691
+ f' - "{GBRIDGE_STATUS_PORT}:{GBRIDGE_STATUS_PORT}"',
692
+ " environment:",
693
+ f" GBRIDGE_CONFIG: '{json.dumps(gcfg)}'",
694
+ ]
695
+
696
+ # each router = its own real C gRouter container (prebuilt image). The gRouter's
697
+ # `tun` links are pure userspace UDP, so no NET_ADMIN / /dev/net/tun needed.
698
+ for r in rt["routers"]:
699
+ lines += [
700
+ f" {r['name']}:",
701
+ f" image: {GROUTER_IMAGE}",
702
+ " pull_policy: never", # the image is built locally, never from a registry
703
+ " networks: [gini]",
704
+ " volumes:",
705
+ f' - "{cap_host}:/captures"', # tap VNF .pcap files land on the host here
706
+ f' - "{scr_host}:/scripts:ro"', # student Lua modules: `gpipe add lua /scripts/x.lua`
707
+ " environment:",
708
+ f" ROUTER_CONFIG: '{json.dumps(r)}'",
709
+ ]
710
+
711
+ # SDN controllers = POX (Python 3) containers, one per controller
712
+ for c in rt["controllers"]:
713
+ lines += [
714
+ f" {c['name']}:",
715
+ f" image: {POX_IMAGE}",
716
+ " pull_policy: never",
717
+ " networks: [gini]",
718
+ " environment:",
719
+ f" POX_APP: '{c['app']}'",
720
+ f" POX_PORT: '{c['port']}'",
721
+ ]
722
+
723
+ # SDN switches = the real gRouter in OpenFlow mode (own container), pointed at
724
+ # their controller via GINI_OF_CONTROLLER. Same image as routers.
725
+ for o in rt["ovs"]:
726
+ of_cfg = {"name": o["name"], "openflow": {"port": o["controller_port"]},
727
+ "ifaces": o["ports"]}
728
+ env = [" environment:", f" ROUTER_CONFIG: '{json.dumps(of_cfg)}'",
729
+ " GINI_OF_CONNECT_DELAY: '2'"]
730
+ if o.get("controller"):
731
+ env.append(f" GINI_OF_CONTROLLER: '{o['controller']}:{o['controller_port']}'")
732
+ depends = ([f" depends_on: [{o['controller']}]"] if o.get("controller") else [])
733
+ lines += [
734
+ f" {o['name']}:",
735
+ f" image: {GROUTER_IMAGE}",
736
+ " pull_policy: never",
737
+ " networks: [gini]",
738
+ " volumes:",
739
+ f' - "{cap_host}:/captures"', # tap VNF .pcap files land on the host here
740
+ f' - "{scr_host}:/scripts:ro"', # student Lua modules: `gpipe add lua /scripts/x.lua`
741
+ *depends,
742
+ *env,
743
+ ]
744
+
745
+ # managed cloud services = ordinary containers from public images on the bridge,
746
+ # reachable by service name (cloud-style discovery). Web consoles are published.
747
+ for s in rt["services"]:
748
+ lines += [
749
+ f" {s['name']}:",
750
+ f" image: {s['image']}",
751
+ f" networks: [{', '.join(s.get('networks') or ['gini'])}]", # VPC/subnet nets, or flat gini
752
+ ]
753
+ # locally-built images (xv6 kernel, OS Zoo emulators) are never on a registry, so tell
754
+ # compose not to try to pull them — otherwise `docker compose up` fails with access denied.
755
+ if str(s.get("image", "")).startswith(("gini-xv6", "gini-oszoo")):
756
+ lines.append(" pull_policy: never")
757
+ if s.get("runtime"): # Kata Instance -> VM isolation
758
+ lines.append(f" runtime: {s['runtime']}")
759
+ if s.get("command"):
760
+ lines.append(" command: " + json.dumps(s["command"]))
761
+ if s.get("env"):
762
+ lines.append(" environment:")
763
+ for k, v in s["env"].items():
764
+ lines.append(f" {k}: '{v}'")
765
+ if s.get("ports"):
766
+ lines.append(" ports:")
767
+ for p in s["ports"]:
768
+ lines.append(f' - "{p["host"]}:{p["container"]}"')
769
+ if s.get("privileged"):
770
+ lines.append(" privileged: true")
771
+ lines += _cpu_limit_lines(s.get("cpus"))
772
+ if s.get("volumes"):
773
+ lines.append(" volumes:")
774
+ for v in s["volumes"]:
775
+ lines.append(f' - "{v}"')
776
+
777
+ # the GINI Cloud Fabric agent — watches every cloud service, serves normalized
778
+ # app-level metrics to gBuilder on a fixed host port.
779
+ fab = rt.get("fabric")
780
+ if fab:
781
+ fcfg = {"services": fab["services"]}
782
+ # the agent polls every cloud service for the dashboard, so it must reach into each
783
+ # VPC — multi-home it onto gini + every VPC network (GINI's own infra, not a tenant
784
+ # resource, so crossing VPC boundaries here is intentional).
785
+ # the agent reaches members over each VPC's internal fabric net (private members
786
+ # live ONLY there); it doesn't need the public egress nets.
787
+ fab_nets = ", ".join(["gini"] + [v["name"] for v in vpc_nets if v.get("internal")])
788
+ lines += [
789
+ " cloudfabric:",
790
+ " build: { context: ., dockerfile: docker/Dockerfile.cloudfabric }",
791
+ f" networks: [{fab_nets}]",
792
+ " environment:",
793
+ f" FABRIC_CONFIG: '{json.dumps(fcfg)}'",
794
+ f" FABRIC_PORT: '{fab['port']}'",
795
+ " ports:",
796
+ f' - "{CLOUDFABRIC_HOST_PORT}:{fab["port"]}"',
797
+ ]
798
+
799
+ # serverless: ONE faas runtime container hosts every Function (multiplexed handlers),
800
+ # reachable by name at http://faas:8000/<name>. Only emitted if the canvas has Functions.
801
+ faas_funcs = rt.get("faas")
802
+ if faas_funcs:
803
+ lines += [
804
+ " faas:",
805
+ " build: { context: ., dockerfile: docker/Dockerfile.faas }",
806
+ " networks: [gini]",
807
+ " environment:",
808
+ f" FAAS_CONFIG: '{json.dumps({'functions': faas_funcs})}'",
809
+ ]
810
+
811
+ # security groups: a tiny iptables sidecar per firewalled member. It shares the member's
812
+ # network namespace (network_mode: service:<member>) so its rules filter the member's
813
+ # traffic without changing the member's image; it installs the rules once and exits.
814
+ for fw in rt.get("firewalls", []):
815
+ member = fw["member"]
816
+ lines += [
817
+ f" {member}_fw:",
818
+ " build: { context: ., dockerfile: docker/Dockerfile.sg }",
819
+ f" network_mode: \"service:{member}\"",
820
+ " cap_add: [NET_ADMIN]",
821
+ f" depends_on: [{member}]",
822
+ " restart: \"no\"",
823
+ " volumes:",
824
+ f' - "./sg/{member}.sh:/sg/run.sh:ro"',
825
+ ' command: ["sh", "/sg/run.sh"]',
826
+ ]
827
+
828
+ # real Kubernetes clusters — k3s in a container (privileged). Pods are scheduled by
829
+ # k3s inside it; gBuilder applies manifests + reads state via `kubectl exec`.
830
+ for k in rt.get("k8s", []):
831
+ lines += [
832
+ f" {k['name']}:",
833
+ f" image: {k['image']}",
834
+ " command: server --disable=traefik --snapshotter=native",
835
+ " privileged: true",
836
+ " tmpfs: [/run, /var/run]",
837
+ " environment:",
838
+ ' K3S_KUBECONFIG_MODE: "644"',
839
+ " networks: [gini]",
840
+ " volumes:",
841
+ f" - ./k8s/{k['name']}:/gini-manifests:ro",
842
+ ]
843
+
844
+ for m in rt["machines"]:
845
+ m = dict(m)
846
+ # faithful mode: a plain host with no internet path of its own drops its docker
847
+ # default route, so it genuinely can't reach the world (the management NIC isn't a
848
+ # back door). Hosts that route to a drawn Internet element keep/replace it instead.
849
+ m["cut_default"] = (not auto_internet and not m.get("fabric_default")
850
+ and not m.get("gateway") and not m.get("nf")) # VNFs are transit
851
+ nets = f"[gini, {WAN_NET}]" if m.get("gateway") else "[gini]"
852
+ # Which toolkit this host was built with. `image:` is given EXPLICITLY so that ten hosts
853
+ # sharing a toolkit resolve to ONE image and compose builds it once — without it, compose
854
+ # tags a separate <project>-<service> image per host and walks the build for each.
855
+ tk = m.get("toolkit", MACHINE_TOOLKIT_DEFAULT)
856
+ image, dockerfile = _MACHINE_IMAGE.get(tk, _MACHINE_IMAGE[MACHINE_LEAN])
857
+ lines += [
858
+ f" {m['name']}:",
859
+ f" hostname: {m.get('hostname', m['name'])}", # `hostname` = the canvas label
860
+ f" image: {image}",
861
+ f" build: {{ context: ., dockerfile: {dockerfile} }}",
862
+ " cap_add: [NET_ADMIN]",
863
+ ' devices: ["/dev/net/tun:/dev/net/tun"]',
864
+ f" networks: {nets}",
865
+ " environment:",
866
+ f" NODE_CONFIG: '{json.dumps(m)}'",
867
+ ]
868
+ if tk == MACHINE_SECURITY: # an IDS host reads the router's Tap FIFO here
869
+ from ..app.paths import captures_dir
870
+ lines += [' volumes:', f' - "{str(captures_dir())}:/captures"']
871
+ if tk == MACHINE_GUI and m.get("novnc_port"): # headful host: publish its noVNC console
872
+ lines += [' ports:', f' - "{m["novnc_port"]}:6080"']
873
+ lines += _cpu_limit_lines(m.get("cpus"))
874
+ return "\n".join(lines) + "\n"
875
+
876
+
877
+ def _cpu_limit_lines(cpus) -> list[str]:
878
+ """Compose lines for a per-container CPU cap from the element's size tier.
879
+ Uses `deploy.resources.limits.cpus`, which `docker compose up` (v2) enforces."""
880
+ try:
881
+ c = float(cpus or 0)
882
+ except (TypeError, ValueError):
883
+ c = 0.0
884
+ if c <= 0:
885
+ return []
886
+ return [" deploy:", " resources:", " limits:", f' cpus: "{c:g}"']
887
+
888
+
889
+ def _startup_ms(created: str, started: str) -> float | None:
890
+ """Milliseconds from a container's Created to its StartedAt — the headline
891
+ VM-vs-container signal (a Kata microVM boots a guest kernel, so it starts much slower
892
+ than a plain container). Tolerates Docker's RFC3339 nanosecond timestamps."""
893
+ import re
894
+ from datetime import datetime
895
+
896
+ def parse(ts: str):
897
+ ts = (ts or "").strip()
898
+ if not ts or ts.startswith("0001"): # Docker's zero value = never started
899
+ return None
900
+ ts = ts.replace("Z", "+00:00")
901
+ m = re.match(r"(.*\.\d{6})\d*([+-]\d\d:\d\d)?$", ts) # trim ns -> microseconds
902
+ if m:
903
+ ts = m.group(1) + (m.group(2) or "")
904
+ try:
905
+ return datetime.fromisoformat(ts)
906
+ except ValueError:
907
+ return None
908
+
909
+ c, s = parse(created), parse(started)
910
+ if c is None or s is None:
911
+ return None
912
+ return round((s - c).total_seconds() * 1000.0, 1)
913
+
914
+
915
+ class Orchestrator:
916
+ """Manages the Docker lifecycle of a compiled topology on the user's machine."""
917
+
918
+ def __init__(self, runtime_dir: str | Path, project: str | None = None) -> None:
919
+ self.runtime_dir = Path(runtime_dir)
920
+ self.workdir: Path | None = None
921
+ self.project = project # docker compose -p <project> (per-student namespacing)
922
+ # mDNS announcer for GINI32 boards. Lives here (on the host) and not in a
923
+ # container because multicast is link-local and does not cross Docker's bridge.
924
+ self._advertiser = None
925
+
926
+ @property
927
+ def _dc(self) -> list:
928
+ """`docker compose` (+ `-p <project>` when namespaced). Use for EVERY compose call
929
+ so read-backs hit the same project the stack was launched under."""
930
+ return ["docker", "compose"] + (["-p", self.project] if self.project else [])
931
+
932
+ def up(self, config: RuntimeConfig, workdir: str | Path,
933
+ auto_internet: bool = True, laptop_id: str = "") -> tuple[bool, str]:
934
+ if config.routers or config.ovs_switches: # routers & OVS use the gRouter image
935
+ ok, msg = self._ensure_grouter_image()
936
+ if not ok:
937
+ return False, msg
938
+ if config.controllers: # SDN controllers use the POX image
939
+ ok, msg = self._ensure_pox_image()
940
+ if not ok:
941
+ return False, msg
942
+ self.workdir = write_project(config, workdir, self.runtime_dir,
943
+ auto_internet, laptop_id)
944
+ # --remove-orphans clears containers from a previous run that are no longer in
945
+ # this compose, so stale services (e.g. an old web app) can't linger on the
946
+ # network and shadow / break name resolution.
947
+ ok, msg = self._compose("up", "--build", "-d", "--remove-orphans")
948
+ if ok:
949
+ self._start_advertiser(config)
950
+ return ok, msg
951
+
952
+ # ---- GINI32 discovery ------------------------------------------------- #
953
+
954
+ def _start_advertiser(self, config: RuntimeConfig) -> None:
955
+ """Announce this lab on the local link, but only while boards are drawn.
956
+
957
+ Advertising unconditionally would let a board latch onto a laptop that is running
958
+ nothing, so the announcement is tied to the lifetime of a topology that actually
959
+ has hardware in it."""
960
+ self._stop_advertiser()
961
+ self.advertise_error = ""
962
+ if not getattr(config, "gbridge", None):
963
+ return
964
+ try:
965
+ from .discovery import GiniAdvertiser
966
+ adv = GiniAdvertiser(
967
+ port=GBRIDGE_HOST_PORT,
968
+ txt={"boards": str(len(config.gbridge)), "port": str(GBRIDGE_HOST_PORT)})
969
+ if adv.start():
970
+ self._advertiser = adv
971
+ else:
972
+ self.advertise_error = adv.error or "could not open port 5353"
973
+ except Exception as exc:
974
+ # discovery is a convenience: a board can always be given an address by hand,
975
+ # so nothing here may take the lab down. It is recorded, never hidden.
976
+ self._advertiser = None
977
+ self.advertise_error = f"{exc.__class__.__name__}: {exc}"
978
+
979
+ def _stop_advertiser(self) -> None:
980
+ if self._advertiser is not None:
981
+ try:
982
+ self._advertiser.stop()
983
+ except Exception:
984
+ pass
985
+ self._advertiser = None
986
+
987
+ @property
988
+ def advertising(self) -> str | None:
989
+ """"<ip>:<port>" while announcing, else None — shown in the UI/diagnostics."""
990
+ a = self._advertiser
991
+ return f"{a.address}:{a.port}" if a is not None and a.running else None
992
+
993
+ def redeploy_faas(self, config: RuntimeConfig, auto_internet: bool = True
994
+ ) -> tuple[bool, str]:
995
+ """Re-deploy ONLY the serverless runtime with the current function code — the GINI
996
+ analogue of AWS 'Deploy'. Regenerates the project files (new FAAS_CONFIG / run_faas.py)
997
+ and recreates just the `faas` container; everything else in the lab keeps running
998
+ (databases, queues, their data). Needs a lab already up (self.workdir set)."""
999
+ if not self.workdir:
1000
+ return False, "the lab isn't running — press Run first"
1001
+ if not config.faas:
1002
+ return False, "no Functions on the canvas to deploy"
1003
+ write_project(config, self.workdir, self.runtime_dir, auto_internet)
1004
+ # --no-deps: don't touch the function's dependencies (queues/DBs stay up);
1005
+ # --force-recreate: pick up the new FAAS_CONFIG even though the image is cached.
1006
+ return self._compose("up", "-d", "--no-deps", "--force-recreate", "--build", "faas")
1007
+
1008
+ @staticmethod
1009
+ def _autobuild_enabled(kind: str) -> bool:
1010
+ """May we build a missing backend image ourselves? `GINI_AUTOBUILD_<KIND>` wins when set
1011
+ (scripts / CI pin it explicitly); otherwise the persisted Settings toggle
1012
+ (Settings → Networking → "Build missing lab images automatically") decides."""
1013
+ env = os.environ.get(f"GINI_AUTOBUILD_{kind}")
1014
+ if env is not None:
1015
+ return env == "1"
1016
+ try:
1017
+ from ..app.paths import load_config
1018
+ return bool(load_config().get("autobuild_images", False))
1019
+ except Exception: # noqa: BLE001 — no config = not enabled
1020
+ return False
1021
+
1022
+ def _ensure_grouter_image(self) -> tuple[bool, str]:
1023
+ """The real gRouter runs from a locally-built image. Check it exists and, if we
1024
+ can find the backend, offer to build it — otherwise return the exact command."""
1025
+ if shutil.which("docker") is None:
1026
+ return False, "docker not found — is Docker installed and running?"
1027
+ present = subprocess.run(["docker", "image", "inspect", GROUTER_IMAGE],
1028
+ capture_output=True, text=True)
1029
+ if present.returncode == 0:
1030
+ return True, "image present"
1031
+ # locate the backend (repo_root/backend) relative to this file
1032
+ backend = Path(__file__).resolve().parents[4] / "backend"
1033
+ dockerfile = backend / "grouter-build" / "Dockerfile"
1034
+ build_cmd = (f"cd {backend} && docker build -f grouter-build/Dockerfile "
1035
+ f"-t {GROUTER_IMAGE} .")
1036
+ if not dockerfile.exists():
1037
+ return False, (f"The real gRouter image '{GROUTER_IMAGE}' isn't built yet, and "
1038
+ f"I can't find the backend to build it.\nBuild it once:\n {build_cmd}")
1039
+ if not self._autobuild_enabled("GROUTER"):
1040
+ return False, (f"The real gRouter image '{GROUTER_IMAGE}' isn't built yet.\n"
1041
+ f"Build it once (takes a couple of minutes):\n {build_cmd}\n"
1042
+ f"…then press Run again.\n"
1043
+ f"(Or tick Settings → Networking → \"Build missing lab images "
1044
+ f"automatically\" and press Run — GINI will build it for you.)")
1045
+ # opt-in auto-build
1046
+ b = subprocess.run(["docker", "build", "-f", "grouter-build/Dockerfile",
1047
+ "-t", GROUTER_IMAGE, "."], cwd=str(backend),
1048
+ capture_output=True, text=True, timeout=1800)
1049
+ if b.returncode != 0:
1050
+ return False, f"Building {GROUTER_IMAGE} failed:\n{(b.stderr or b.stdout)[-800:]}"
1051
+ return True, f"built {GROUTER_IMAGE}"
1052
+
1053
+ def _ensure_pox_image(self) -> tuple[bool, str]:
1054
+ """The SDN controller runs from a locally-built POX image."""
1055
+ if shutil.which("docker") is None:
1056
+ return False, "docker not found — is Docker installed and running?"
1057
+ present = subprocess.run(["docker", "image", "inspect", POX_IMAGE],
1058
+ capture_output=True, text=True)
1059
+ if present.returncode == 0:
1060
+ return True, "image present"
1061
+ sdn = Path(__file__).resolve().parents[4] / "backend" / "sdn"
1062
+ build_cmd = f"cd {sdn} && docker build -t {POX_IMAGE} ."
1063
+ if not (sdn / "Dockerfile").exists():
1064
+ return False, (f"The POX image '{POX_IMAGE}' isn't built yet, and I can't find "
1065
+ f"backend/sdn to build it.\nBuild it once:\n {build_cmd}")
1066
+ if not self._autobuild_enabled("POX"):
1067
+ return False, (f"The SDN controller image '{POX_IMAGE}' isn't built yet.\n"
1068
+ f"Build it once:\n {build_cmd}\n…then press Run again.\n"
1069
+ f"(Or tick Settings → Networking → \"Build missing lab images "
1070
+ f"automatically\" and press Run — GINI will build it for you.)")
1071
+ b = subprocess.run(["docker", "build", "-t", POX_IMAGE, "."], cwd=str(sdn),
1072
+ capture_output=True, text=True, timeout=1800)
1073
+ if b.returncode != 0:
1074
+ return False, f"Building {POX_IMAGE} failed:\n{(b.stderr or b.stdout)[-800:]}"
1075
+ return True, f"built {POX_IMAGE}"
1076
+
1077
+ def down(self) -> tuple[bool, str]:
1078
+ self._stop_advertiser() # stop announcing before the relay goes away
1079
+ if not self.workdir:
1080
+ return True, "nothing running"
1081
+ return self._compose("down")
1082
+
1083
+ def status(self, workdir: str | Path | None = None) -> dict[str, str]:
1084
+ """Map service -> state ('running' / 'exited' / ...) via `docker compose ps`."""
1085
+ wd = workdir or self.workdir
1086
+ if not wd:
1087
+ return {}
1088
+ try:
1089
+ r = subprocess.run([*self._dc, "ps", "--format", "json"],
1090
+ cwd=str(wd), capture_output=True, text=True, timeout=20)
1091
+ except (FileNotFoundError, subprocess.TimeoutExpired):
1092
+ return {}
1093
+ out = (r.stdout or "").strip()
1094
+ if not out:
1095
+ return {}
1096
+ rows: list = []
1097
+ try:
1098
+ data = json.loads(out)
1099
+ rows = data if isinstance(data, list) else [data]
1100
+ except json.JSONDecodeError:
1101
+ for line in out.splitlines():
1102
+ try:
1103
+ rows.append(json.loads(line))
1104
+ except json.JSONDecodeError:
1105
+ pass
1106
+ states: dict[str, str] = {}
1107
+ for row in rows:
1108
+ svc = row.get("Service") or row.get("Name")
1109
+ raw = str(row.get("State") or row.get("Status", "")).lower()
1110
+ if svc:
1111
+ states[svc] = "running" if "running" in raw or "up" in raw else raw
1112
+ return states
1113
+
1114
+ def update_cpus(self, service: str, cpus: float,
1115
+ workdir: str | Path | None = None) -> tuple[bool, str]:
1116
+ """Live-change a running container's CPU cap (vertical scaling), no restart, via
1117
+ `docker update --cpus`. Resolves the container id through `docker compose ps`."""
1118
+ wd = workdir or self.workdir
1119
+ if not wd:
1120
+ return False, "not running"
1121
+ try:
1122
+ r = subprocess.run([*self._dc, "ps", "-q", service],
1123
+ cwd=str(wd), capture_output=True, text=True, timeout=20)
1124
+ ids = (r.stdout or "").strip().splitlines()
1125
+ if not ids or not ids[0]:
1126
+ return False, f"{service}: no running container"
1127
+ u = subprocess.run(["docker", "update", "--cpus", f"{cpus:g}", ids[0]],
1128
+ capture_output=True, text=True, timeout=20)
1129
+ return u.returncode == 0, (u.stderr or u.stdout).strip()
1130
+ except FileNotFoundError:
1131
+ return False, "docker not found — is Docker installed and running?"
1132
+ except subprocess.TimeoutExpired:
1133
+ return False, "docker update timed out"
1134
+
1135
+ def stats(self, service: str, workdir: str | Path | None = None) -> dict | None:
1136
+ """One cheap sample of a running container's CPU% and memory (MiB) via
1137
+ `docker stats --no-stream` (just reads cgroup counters). None if unavailable."""
1138
+ wd = workdir or self.workdir
1139
+ if not wd:
1140
+ return None
1141
+ try:
1142
+ r = subprocess.run([*self._dc, "ps", "-q", service],
1143
+ cwd=str(wd), capture_output=True, text=True, timeout=15)
1144
+ ids = (r.stdout or "").strip().splitlines()
1145
+ if not ids or not ids[0]:
1146
+ return None
1147
+ s = subprocess.run(
1148
+ ["docker", "stats", "--no-stream", "--format",
1149
+ "{{.CPUPerc}}|{{.MemUsage}}|{{.NetIO}}", ids[0]],
1150
+ capture_output=True, text=True, timeout=15)
1151
+ line = (s.stdout or "").strip()
1152
+ if s.returncode != 0 or not line:
1153
+ return None
1154
+ parts = line.split("|")
1155
+ cpu = float(parts[0].strip().rstrip("%") or 0)
1156
+ mem = _parse_mem_mib(parts[1].split("/")[0]) if len(parts) > 1 else 0.0
1157
+ net = 0.0
1158
+ if len(parts) > 2: # "1.2kB / 3.4kB" (rx / tx) -> total bytes
1159
+ rx, _, tx = parts[2].partition("/")
1160
+ net = _parse_bytes(rx) + _parse_bytes(tx)
1161
+ return {"cpu": cpu, "mem_used": mem, "net_bytes": net}
1162
+ except (FileNotFoundError, subprocess.TimeoutExpired, ValueError):
1163
+ return None
1164
+
1165
+ def k8s_apply(self, service: str) -> tuple[bool, str]:
1166
+ """Wait for the k3s API to be Ready, then `kubectl apply` the generated manifests
1167
+ (run inside the cluster container — k3s ships kubectl, so no host kubectl needed)."""
1168
+ if not self.workdir:
1169
+ return False, "not running"
1170
+ base = [*self._dc, "exec", "-T", service, "kubectl"]
1171
+ for _ in range(40): # k3s takes ~15-30s to come up
1172
+ try:
1173
+ r = subprocess.run(base + ["get", "nodes", "--no-headers"],
1174
+ cwd=str(self.workdir), capture_output=True,
1175
+ text=True, timeout=20)
1176
+ except (FileNotFoundError, subprocess.TimeoutExpired):
1177
+ return False, "docker/cluster not reachable"
1178
+ if r.returncode == 0 and " Ready" in (" " + r.stdout):
1179
+ break
1180
+ time.sleep(3)
1181
+ else:
1182
+ return False, "k3s API did not become Ready in time"
1183
+ a = subprocess.run(base + ["apply", "-f", "/gini-manifests/"],
1184
+ cwd=str(self.workdir), capture_output=True, text=True, timeout=60)
1185
+ return a.returncode == 0, (a.stderr or a.stdout).strip()
1186
+
1187
+ def k8s_pods(self, service: str) -> list:
1188
+ """`kubectl get pods -o json` inside the cluster -> [{name,app,phase,node,restarts}]."""
1189
+ if not self.workdir:
1190
+ return []
1191
+ try:
1192
+ r = subprocess.run(
1193
+ [*self._dc, "exec", "-T", service, "kubectl", "get", "pods",
1194
+ "-A", "-o", "json"], cwd=str(self.workdir),
1195
+ capture_output=True, text=True, timeout=20)
1196
+ except (FileNotFoundError, subprocess.TimeoutExpired):
1197
+ return []
1198
+ if r.returncode != 0:
1199
+ return []
1200
+ try:
1201
+ data = json.loads(r.stdout)
1202
+ except json.JSONDecodeError:
1203
+ return []
1204
+ pods = []
1205
+ for it in data.get("items", []):
1206
+ md, st = it.get("metadata", {}), it.get("status", {})
1207
+ if md.get("namespace") in ("kube-system",): # hide cluster infra pods
1208
+ continue
1209
+ pods.append({"name": md.get("name"),
1210
+ "app": (md.get("labels") or {}).get("app"),
1211
+ "phase": st.get("phase"),
1212
+ "node": (it.get("spec") or {}).get("nodeName")})
1213
+ return pods
1214
+
1215
+ def k8s_metrics(self, service: str) -> dict:
1216
+ """Per-deployment K8s metrics for the Live view: replicas, CPU% vs HPA target,
1217
+ min/max. From `kubectl get hpa,deploy -o json` (one exec). {deployments:{name:{…}}}."""
1218
+ if not self.workdir:
1219
+ return {}
1220
+ try:
1221
+ r = subprocess.run(
1222
+ [*self._dc, "exec", "-T", service, "kubectl",
1223
+ "get", "hpa,deploy", "-o", "json"], cwd=str(self.workdir),
1224
+ capture_output=True, text=True, timeout=20)
1225
+ except (FileNotFoundError, subprocess.TimeoutExpired):
1226
+ return {}
1227
+ if r.returncode != 0:
1228
+ return {}
1229
+ try:
1230
+ data = json.loads(r.stdout)
1231
+ except json.JSONDecodeError:
1232
+ return {}
1233
+ deps: dict = {}
1234
+ for it in data.get("items", []):
1235
+ md = it.get("metadata", {}) or {}
1236
+ if md.get("namespace") in ("kube-system", "kube-public", "kube-node-lease"):
1237
+ continue
1238
+ kind, nm = it.get("kind"), md.get("name")
1239
+ if kind == "Deployment":
1240
+ d = deps.setdefault(nm, {})
1241
+ d["replicas"] = (it.get("status") or {}).get("readyReplicas", 0)
1242
+ d["desired"] = (it.get("spec") or {}).get("replicas", 0)
1243
+ elif kind == "HorizontalPodAutoscaler":
1244
+ sp, st = it.get("spec", {}) or {}, it.get("status", {}) or {}
1245
+ tgt = (sp.get("scaleTargetRef") or {}).get("name", nm)
1246
+ d = deps.setdefault(tgt, {})
1247
+ d["min"], d["max"] = sp.get("minReplicas"), sp.get("maxReplicas")
1248
+ d["hpa"] = nm
1249
+ if st.get("currentReplicas") is not None:
1250
+ d["replicas"] = st["currentReplicas"]
1251
+ for m in sp.get("metrics", []) or []:
1252
+ res = m.get("resource") or {}
1253
+ if res.get("name") == "cpu":
1254
+ d["target_pct"] = (res.get("target") or {}).get("averageUtilization")
1255
+ for cm in st.get("currentMetrics", []) or []:
1256
+ res = cm.get("resource") or {}
1257
+ if res.get("name") == "cpu":
1258
+ d["cpu_pct"] = (res.get("current") or {}).get("averageUtilization")
1259
+ if d.get("target_pct") is None:
1260
+ d["target_pct"] = sp.get("targetCPUUtilizationPercentage")
1261
+ if d.get("cpu_pct") is None:
1262
+ d["cpu_pct"] = st.get("currentCPUUtilizationPercentage")
1263
+ pods = sum(int(d.get("desired") or d.get("replicas") or 0) for d in deps.values())
1264
+ return {"deployments": deps, "pods": pods}
1265
+
1266
+ def k8s_scale(self, service: str, deployment: str, replicas) -> tuple[bool, str]:
1267
+ """Live `kubectl scale` a Deployment (the Pod Replicas slider)."""
1268
+ if not self.workdir:
1269
+ return False, "not running"
1270
+ try:
1271
+ r = subprocess.run(
1272
+ [*self._dc, "exec", "-T", service, "kubectl", "scale",
1273
+ f"deployment/{deployment}", f"--replicas={int(replicas)}"],
1274
+ cwd=str(self.workdir), capture_output=True, text=True, timeout=20)
1275
+ return r.returncode == 0, (r.stderr or r.stdout).strip()
1276
+ except (FileNotFoundError, subprocess.TimeoutExpired, ValueError) as e:
1277
+ return False, str(e)
1278
+
1279
+ def k8s_set_hpa(self, service: str, hpa: str, target=None, mn=None,
1280
+ mx=None) -> tuple[bool, str]:
1281
+ """Live-patch an HPA's target CPU% / min / max (the Autoscaling Group sliders)."""
1282
+ if not self.workdir:
1283
+ return False, "not running"
1284
+ spec: dict = {}
1285
+ if mn is not None:
1286
+ spec["minReplicas"] = int(mn)
1287
+ if mx is not None:
1288
+ spec["maxReplicas"] = int(mx)
1289
+ if target is not None:
1290
+ spec["metrics"] = [{"type": "Resource", "resource": {"name": "cpu",
1291
+ "target": {"type": "Utilization", "averageUtilization": int(target)}}}]
1292
+ try:
1293
+ r = subprocess.run(
1294
+ [*self._dc, "exec", "-T", service, "kubectl", "patch",
1295
+ f"hpa/{hpa}", "--type", "merge", "-p", json.dumps({"spec": spec})],
1296
+ cwd=str(self.workdir), capture_output=True, text=True, timeout=20)
1297
+ return r.returncode == 0, (r.stderr or r.stdout).strip()
1298
+ except (FileNotFoundError, subprocess.TimeoutExpired, ValueError) as e:
1299
+ return False, str(e)
1300
+
1301
+ def stats_all(self, workdir: str | Path | None = None) -> dict:
1302
+ """One `docker stats --no-stream` for ALL containers -> {service: {cpu,mem_used,
1303
+ net_bytes}}. One call keeps every element's history live regardless of selection."""
1304
+ wd = workdir or self.workdir
1305
+ if not wd:
1306
+ return {}
1307
+ try:
1308
+ s = subprocess.run(
1309
+ ["docker", "stats", "--no-stream", "--format",
1310
+ "{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}|{{.NetIO}}"],
1311
+ cwd=str(wd), capture_output=True, text=True, timeout=20)
1312
+ except (FileNotFoundError, subprocess.TimeoutExpired):
1313
+ return {}
1314
+ if s.returncode != 0:
1315
+ return {}
1316
+ import re
1317
+ out: dict = {}
1318
+ for line in (s.stdout or "").strip().splitlines():
1319
+ parts = line.split("|")
1320
+ if len(parts) < 4:
1321
+ continue
1322
+ m = re.search(r"[-_]([a-z0-9]+)[-_]\d+$", parts[0].strip()) # …_<svc>_1
1323
+ if not m:
1324
+ continue
1325
+ try:
1326
+ cpu = float(parts[1].strip().rstrip("%") or 0)
1327
+ except ValueError:
1328
+ cpu = 0.0
1329
+ rx, _, tx = parts[3].partition("/")
1330
+ out[m.group(1)] = {"cpu": cpu,
1331
+ "mem_used": _parse_mem_mib(parts[2].split("/")[0]),
1332
+ "net_bytes": _parse_bytes(rx) + _parse_bytes(tx)}
1333
+ return out
1334
+
1335
+ def runtime_available(self, name: str, workdir: str | Path | None = None) -> bool:
1336
+ """Whether the active Docker daemon has an OCI runtime registered under `name`
1337
+ (e.g. 'kata'). Used to gate the Kata Instance element + warn on Run."""
1338
+ wd = workdir or self.workdir
1339
+ try:
1340
+ r = subprocess.run(["docker", "info", "--format", "{{json .Runtimes}}"],
1341
+ cwd=(str(wd) if wd else None),
1342
+ capture_output=True, text=True, timeout=15)
1343
+ if r.returncode != 0:
1344
+ return False
1345
+ import json
1346
+ return name in (json.loads(r.stdout or "{}") or {})
1347
+ except (FileNotFoundError, subprocess.TimeoutExpired, ValueError):
1348
+ return False
1349
+
1350
+ def startup_times(self, workdir: str | Path | None = None) -> dict:
1351
+ """Per-element startup time in ms (Created -> StartedAt) for the running stack —
1352
+ the VM-vs-container experiment's headline metric. {service: ms}."""
1353
+ wd = workdir or self.workdir
1354
+ if not wd:
1355
+ return {}
1356
+ import re
1357
+ try:
1358
+ ids = subprocess.run([*self._dc, "ps", "-q"], cwd=str(wd),
1359
+ capture_output=True, text=True, timeout=20).stdout.split()
1360
+ if not ids:
1361
+ return {}
1362
+ r = subprocess.run(
1363
+ ["docker", "inspect", "--format",
1364
+ "{{.Name}}\t{{.Created}}\t{{.State.StartedAt}}", *ids],
1365
+ cwd=str(wd), capture_output=True, text=True, timeout=20)
1366
+ except (FileNotFoundError, subprocess.TimeoutExpired):
1367
+ return {}
1368
+ out: dict = {}
1369
+ for line in (r.stdout or "").splitlines():
1370
+ parts = line.split("\t")
1371
+ if len(parts) != 3:
1372
+ continue
1373
+ m = re.search(r"[-_]([a-z0-9]+)[-_]\d+$", parts[0].strip().lstrip("/"))
1374
+ ms = _startup_ms(parts[1], parts[2])
1375
+ if m and ms is not None:
1376
+ out[m.group(1)] = ms
1377
+ return out
1378
+
1379
+ def drive_load(self, host_port: int, url: str, qps, conns=8,
1380
+ dur: str = "3600s") -> tuple[bool, str]:
1381
+ """Drive a Fortio load generator via its REST API: (re)start a continuous run at
1382
+ `qps` against `url`. Stops any current run first, so this doubles as the throttle."""
1383
+ import urllib.parse
1384
+ import urllib.request
1385
+ base = f"http://localhost:{host_port}/fortio/rest"
1386
+ try:
1387
+ urllib.request.urlopen(base + "/stop", timeout=5)
1388
+ except Exception: # noqa: BLE001 — nothing running yet is fine
1389
+ pass
1390
+ q = urllib.parse.urlencode({"url": url, "qps": qps, "t": dur,
1391
+ "c": conns, "async": "on"})
1392
+ try:
1393
+ urllib.request.urlopen(f"{base}/run?{q}", timeout=5).read()
1394
+ return True, f"load → {url} @ {qps} req/s"
1395
+ except Exception as e: # noqa: BLE001
1396
+ return False, str(e)
1397
+
1398
+ def stop_load(self, host_port: int) -> tuple[bool, str]:
1399
+ import urllib.request
1400
+ try:
1401
+ urllib.request.urlopen(f"http://localhost:{host_port}/fortio/rest/stop",
1402
+ timeout=5)
1403
+ return True, "stopped"
1404
+ except Exception as e: # noqa: BLE001
1405
+ return False, str(e)
1406
+
1407
+ def fabric_metrics(self) -> dict | None:
1408
+ """Poll the GINI Cloud Fabric agent's normalized app-level metrics for the lab.
1409
+ None if the lab isn't running or the agent isn't reachable yet."""
1410
+ if not self.workdir:
1411
+ return None
1412
+ try:
1413
+ import urllib.request
1414
+ url = f"http://localhost:{CLOUDFABRIC_HOST_PORT}/metrics.json"
1415
+ with urllib.request.urlopen(url, timeout=3) as r:
1416
+ return json.loads(r.read().decode("utf-8", "replace"))
1417
+ except Exception: # noqa: BLE001 — agent may not be up yet
1418
+ return None
1419
+
1420
+ def board_action(self, action: str, board: str) -> bool:
1421
+ """claim / release / blink a real board. Queued by the relay and delivered on
1422
+ the board's next contact, which is when we actually know where it is."""
1423
+ if not self.workdir or action not in ("claim", "release", "blink"):
1424
+ return False
1425
+ try:
1426
+ import urllib.request
1427
+ req = urllib.request.Request(
1428
+ f"http://localhost:{GBRIDGE_STATUS_PORT}/{action}",
1429
+ data=json.dumps({"board": board}).encode(),
1430
+ headers={"Content-Type": "application/json"}, method="POST")
1431
+ with urllib.request.urlopen(req, timeout=2) as r:
1432
+ return bool(json.loads(r.read().decode()).get("ok"))
1433
+ except Exception: # noqa: BLE001 — relay may not be up
1434
+ return False
1435
+
1436
+ def board_status(self) -> dict | None:
1437
+ """Live state of every real GINI32 board: online, signal, connected devices.
1438
+
1439
+ The UI calls only this, never the relay directly, so moving the relay out of
1440
+ the container later is invisible above this line.
1441
+ """
1442
+ if not self.workdir:
1443
+ return None
1444
+ try:
1445
+ import urllib.request
1446
+ url = f"http://localhost:{GBRIDGE_STATUS_PORT}/"
1447
+ with urllib.request.urlopen(url, timeout=2) as r:
1448
+ return json.loads(r.read().decode("utf-8", "replace"))
1449
+ except Exception: # noqa: BLE001 — relay may not be up yet, or no boards
1450
+ return None
1451
+
1452
+ def _compose(self, *args: str) -> tuple[bool, str]:
1453
+ try:
1454
+ r = subprocess.run([*self._dc, *args], cwd=str(self.workdir),
1455
+ capture_output=True, text=True, timeout=600)
1456
+ return r.returncode == 0, (r.stderr or r.stdout).strip()
1457
+ except FileNotFoundError:
1458
+ return False, "docker not found — is Docker installed and running?"
1459
+ except subprocess.TimeoutExpired:
1460
+ return False, "docker compose timed out"