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
gini/server/app.py ADDED
@@ -0,0 +1,140 @@
1
+ """The GINI server request router + a stdlib HTTP wrapper.
2
+
3
+ `GiniServer.handle(method, path, token, body)` is the whole API as a pure function of its
4
+ inputs (so it's unit-testable with a fake orchestrator, no Docker). The HTTP layer below is
5
+ a thin shell that parses requests and calls it.
6
+
7
+ Flow for /run: take the student's **topology** -> GINI's own compiler -> policy.enforce ->
8
+ the student's namespaced orchestrator. The student never supplies a compose file or a
9
+ Docker command.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import threading
15
+
16
+ from ..domain.topology import Topology
17
+ from ..services.compiler import RuntimeCompiler
18
+ from .auth import Tokens, UserStore
19
+ from .policy import PolicyError, default_allowed_images, enforce
20
+ from .session import SessionManager
21
+
22
+
23
+ class GiniServer:
24
+ def __init__(self, users: UserStore, tokens: Tokens, sessions: SessionManager,
25
+ orchestrator_factory, allowed_images=None, max_cpus: float = 2.0) -> None:
26
+ self.users = users
27
+ self.tokens = tokens
28
+ self.sessions = sessions
29
+ self._make_orch = orchestrator_factory # (project, workdir) -> orchestrator
30
+ self.allowed_images = set(allowed_images) if allowed_images else default_allowed_images()
31
+ self.max_cpus = max_cpus
32
+ self._orch: dict = {} # user -> orchestrator
33
+ self._runs: dict = {} # user -> {"state", "message"}
34
+ self._lock = threading.Lock()
35
+
36
+ def _orch_for(self, user: str):
37
+ if user not in self._orch:
38
+ self._orch[user] = self._make_orch(
39
+ self.sessions.project_name(user), self.sessions.workdir(user))
40
+ return self._orch[user]
41
+
42
+ # the entire API as a pure (status, payload) function -------------------- #
43
+ def handle(self, method: str, path: str, token: str | None, body: dict) -> tuple[int, dict]:
44
+ if method == "POST" and path == "/login":
45
+ u, p = body.get("username"), body.get("password")
46
+ if self.users.verify(u, p):
47
+ return 200, {"token": self.tokens.mint(u)}
48
+ return 401, {"error": "invalid credentials"}
49
+
50
+ user = self.tokens.verify(token or "")
51
+ if not user:
52
+ return 401, {"error": "authentication required"}
53
+
54
+ try:
55
+ if method == "POST" and path == "/run":
56
+ return self._run(user, body)
57
+ if method == "POST" and path == "/stop":
58
+ ok, msg = self._orch_for(user).down()
59
+ with self._lock:
60
+ self._runs[user] = {"state": "stopped", "message": msg}
61
+ return (200 if ok else 500), {"ok": ok, "message": msg}
62
+ if method == "GET" and path == "/status":
63
+ with self._lock:
64
+ run = dict(self._runs.get(user, {"state": "stopped", "message": ""}))
65
+ return 200, {"status": self._orch_for(user).status(), "run": run}
66
+ if method == "GET" and path == "/metrics":
67
+ o = self._orch_for(user)
68
+ return 200, {"stats": o.stats_all(), "startup": o.startup_times()}
69
+ if method == "GET" and path == "/capabilities":
70
+ return 200, {"kata": self._orch_for(user).runtime_available("kata")}
71
+ except PolicyError as e:
72
+ return 400, {"error": str(e)}
73
+ except Exception as e: # noqa: BLE001 — report, don't crash
74
+ return 500, {"error": str(e)}
75
+ return 404, {"error": "not found"}
76
+
77
+ def _run(self, user: str, body: dict) -> tuple[int, dict]:
78
+ if "topology" not in body:
79
+ return 400, {"error": "missing topology"}
80
+ topo = Topology.from_dict(body["topology"]) # the student described elements + links
81
+ cfg = RuntimeCompiler().compile(topo) # WE compile it, with our trusted compiler
82
+ enforce(cfg, self.allowed_images, self.max_cpus) # raises PolicyError on a violation (fast)
83
+ o = self._orch_for(user)
84
+ workdir = str(self.sessions.workdir(user))
85
+ with self._lock:
86
+ self._runs[user] = {"state": "starting", "message": ""}
87
+
88
+ # `docker compose up` can take minutes on first run (image pulls + builds), so run it
89
+ # in the background and let the client poll /status — never block the HTTP request.
90
+ def launch():
91
+ try:
92
+ ok, msg = o.up(cfg, workdir)
93
+ state = "running" if ok else "error"
94
+ except Exception as e: # noqa: BLE001
95
+ ok, msg, state = False, str(e), "error"
96
+ with self._lock:
97
+ self._runs[user] = {"state": state, "message": msg}
98
+
99
+ threading.Thread(target=launch, daemon=True).start()
100
+ return 202, {"ok": True, "state": "starting"}
101
+
102
+
103
+ # --------------------------------------------------------------------------- #
104
+ # Thin HTTP shell (stdlib). Production entry point lives in __main__.py.
105
+ # --------------------------------------------------------------------------- #
106
+ def serve(server: "GiniServer", host: str = "0.0.0.0", port: int = 10000) -> None:
107
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
108
+
109
+ class Handler(BaseHTTPRequestHandler):
110
+ def log_message(self, *a): # quiet
111
+ pass
112
+
113
+ def _token(self) -> str | None:
114
+ auth = self.headers.get("Authorization", "")
115
+ return auth[7:] if auth.startswith("Bearer ") else None
116
+
117
+ def _dispatch(self, method: str) -> None:
118
+ n = int(self.headers.get("Content-Length", 0) or 0)
119
+ raw = self.rfile.read(n).decode("utf-8") if n else ""
120
+ try:
121
+ body = json.loads(raw) if raw else {}
122
+ except json.JSONDecodeError:
123
+ body = {}
124
+ status, payload = server.handle(method, self.path.split("?", 1)[0],
125
+ self._token(), body if isinstance(body, dict) else {})
126
+ data = json.dumps(payload).encode()
127
+ self.send_response(status)
128
+ self.send_header("Content-Type", "application/json")
129
+ self.send_header("Content-Length", str(len(data)))
130
+ self.end_headers()
131
+ self.wfile.write(data)
132
+
133
+ def do_GET(self):
134
+ self._dispatch("GET")
135
+
136
+ def do_POST(self):
137
+ self._dispatch("POST")
138
+
139
+ print(f"[gini-server] listening on {host}:{port}", flush=True)
140
+ ThreadingHTTPServer((host, port), Handler).serve_forever()
gini/server/auth.py ADDED
@@ -0,0 +1,82 @@
1
+ """Authentication for the GINI server: a flat user file + signed session tokens.
2
+
3
+ The user file is JSON: ``{"<username>": {"salt": "<hex>", "hash": "<hex>"}}``. Passwords
4
+ are PBKDF2-HMAC-SHA256 — the server never stores (or sees, after login) a plaintext
5
+ password. Tokens are HMAC-signed with the server secret and carry an expiry, so the
6
+ server stays stateless: no session table, nothing to leak.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import hmac
13
+ import json
14
+ import os
15
+ import time
16
+ from pathlib import Path
17
+
18
+ _ITER = 200_000 # PBKDF2 iterations
19
+
20
+
21
+ def hash_password(password: str, salt: bytes | None = None) -> dict:
22
+ """Make a {salt, hash} record for the user file (used by the add-user admin tool)."""
23
+ salt = salt or os.urandom(16)
24
+ h = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, _ITER)
25
+ return {"salt": salt.hex(), "hash": h.hex()}
26
+
27
+
28
+ def _check(password: str, rec: dict) -> bool:
29
+ try:
30
+ salt = bytes.fromhex(rec["salt"])
31
+ h = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, _ITER)
32
+ return hmac.compare_digest(h.hex(), rec["hash"])
33
+ except (KeyError, ValueError, TypeError):
34
+ return False
35
+
36
+
37
+ class UserStore:
38
+ """A flat username -> {salt, hash} table."""
39
+
40
+ def __init__(self, users: dict | None = None) -> None:
41
+ self._users = dict(users or {})
42
+
43
+ @classmethod
44
+ def from_file(cls, path: str | Path) -> "UserStore":
45
+ p = Path(path)
46
+ return cls(json.loads(p.read_text()) if p.exists() else {})
47
+
48
+ def verify(self, username: str, password: str) -> bool:
49
+ rec = self._users.get(username or "")
50
+ return bool(rec) and _check(password or "", rec)
51
+
52
+
53
+ class Tokens:
54
+ """Mint / verify HMAC-signed bearer tokens (`<payload>.<sig>`), so auth is stateless."""
55
+
56
+ def __init__(self, secret: bytes, ttl: int = 8 * 3600) -> None:
57
+ self._secret = secret
58
+ self._ttl = ttl
59
+
60
+ def mint(self, username: str) -> str:
61
+ payload = {"u": username, "exp": int(time.time()) + self._ttl}
62
+ body = base64.urlsafe_b64encode(json.dumps(payload).encode())
63
+ sig = hmac.new(self._secret, body, hashlib.sha256).digest()
64
+ return body.decode() + "." + base64.urlsafe_b64encode(sig).decode()
65
+
66
+ def verify(self, token: str) -> str | None:
67
+ """Return the username if the token is valid + unexpired, else None."""
68
+ try:
69
+ body_s, sig_s = (token or "").split(".", 1)
70
+ body = body_s.encode()
71
+ # compare the base64 signature strings directly (b64decode is lenient about
72
+ # trailing junk, which would let a tampered token slip through).
73
+ expect = base64.urlsafe_b64encode(
74
+ hmac.new(self._secret, body, hashlib.sha256).digest()).decode()
75
+ if not hmac.compare_digest(sig_s, expect):
76
+ return None
77
+ payload = json.loads(base64.urlsafe_b64decode(body))
78
+ if int(payload.get("exp", 0)) < time.time():
79
+ return None
80
+ return payload.get("u")
81
+ except (ValueError, KeyError, json.JSONDecodeError):
82
+ return None
gini/server/policy.py ADDED
@@ -0,0 +1,57 @@
1
+ """Server-side policy — the single point that decides what may actually run.
2
+
3
+ The student only ever sends a *topology*; the GINI server compiles it with GINI's own
4
+ trusted compiler, then this validates + sanitizes the compiled `RuntimeConfig` before it
5
+ can touch Docker. A student therefore cannot make the daemon do anything dangerous: no
6
+ arbitrary images, no privileged containers, no host bind mounts, no non-Kata runtimes, no
7
+ fabric/router/SDN/k8s/serverless (out of scope for a Kata experiment box), and resource
8
+ use is capped. Published host ports are stripped — the server proxies consoles, which also
9
+ avoids port collisions between students.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ ALLOWED_RUNTIMES = {"", "kata"} # plain container (runc) or a Kata microVM
14
+ # only the cloud-plane experiment subset runs here:
15
+ _FORBIDDEN_SECTIONS = ("machines", "routers", "ovs_switches", "controllers", "k8s", "faas")
16
+
17
+
18
+ class PolicyError(Exception):
19
+ """A topology that compiled to something the server refuses to run."""
20
+
21
+
22
+ def _image_base(image: str) -> str:
23
+ return (image or "").split("@", 1)[0] # drop any digest; keep repo:tag
24
+
25
+
26
+ def enforce(config, allowed_images, max_cpus: float = 2.0):
27
+ """Validate + sanitize a compiled RuntimeConfig *in place*; raise PolicyError on any
28
+ violation. Returns the (sanitized) config on success."""
29
+ for sec in _FORBIDDEN_SECTIONS:
30
+ if getattr(config, sec, None):
31
+ raise PolicyError(
32
+ f"this backend runs only Kata/container service topologies (found {sec})")
33
+ allow = set(allowed_images)
34
+ for s in config.services:
35
+ if _image_base(s.image) not in allow:
36
+ raise PolicyError(f"image not allowed: {s.image}")
37
+ if s.runtime not in ALLOWED_RUNTIMES:
38
+ raise PolicyError(f"runtime not allowed: {s.runtime!r}")
39
+ if s.privileged:
40
+ raise PolicyError("privileged containers are not allowed")
41
+ for v in s.volumes:
42
+ src = v.split(":", 1)[0]
43
+ if not src.startswith("."): # only project-relative mounts
44
+ raise PolicyError(f"host bind mount not allowed: {v}")
45
+ if s.cpus and s.cpus > max_cpus:
46
+ s.cpus = max_cpus # clamp to the per-student cap
47
+ s.ports = [] # never publish to the host (proxy instead)
48
+ return config
49
+
50
+
51
+ def default_allowed_images() -> set:
52
+ """The curated image set students may run: exactly the images GINI itself uses (the
53
+ managed-service catalog) plus the compute base images. Anything else is rejected."""
54
+ from ..services.cloud_catalog import CATALOG
55
+ base = {_image_base(svc.image) for svc in CATALOG.values()}
56
+ base |= {"ubuntu:22.04", "alpine:latest"} # Instance / Kata Instance / Container
57
+ return base
gini/server/session.py ADDED
@@ -0,0 +1,23 @@
1
+ """Per-student isolation: a stable namespaced compose project + workdir per user, so two
2
+ students sharing one Kata host never collide (project names, container names, files)."""
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+
9
+ def _slug(name: str) -> str:
10
+ return re.sub(r"[^a-z0-9]", "", (name or "").lower()) or "user"
11
+
12
+
13
+ class SessionManager:
14
+ def __init__(self, base_dir: str | Path) -> None:
15
+ self._base = Path(base_dir)
16
+
17
+ def project_name(self, user: str) -> str:
18
+ """The docker compose project (`-p`) for this user — keeps stacks separate."""
19
+ return "gini-" + _slug(user)
20
+
21
+ def workdir(self, user: str) -> Path:
22
+ """A per-user project directory under the server's base dir."""
23
+ return self._base / _slug(user)
@@ -0,0 +1,15 @@
1
+ from .compiler import RuntimeCompiler, RuntimeConfig
2
+ from .gloader import GLoader
3
+ from .orchestrator import Orchestrator, Sim, simulate, write_project
4
+ from .persistence import PROJECT_EXT, load_project, save_project
5
+ from .project import (
6
+ delete_experiment, experiment_path, is_project_dir, list_experiments, list_projects,
7
+ load_experiment, load_project_dir, rename_experiment, safe_name, save_experiment,
8
+ save_project_dir,
9
+ )
10
+ from .terminal import open_terminal
11
+
12
+ __all__ = ["RuntimeCompiler", "RuntimeConfig", "GLoader", "Orchestrator", "Sim",
13
+ "simulate", "write_project", "open_terminal",
14
+ "PROJECT_EXT", "load_project", "save_project",
15
+ "is_project_dir", "list_projects", "load_project_dir", "save_project_dir"]
@@ -0,0 +1,248 @@
1
+ """Put firmware on a GINI32 board over USB, from inside gBuilder.
2
+
3
+ This closes a gap that made the Hardware menu untrue. `boardsetup` drives the board's
4
+ `gini> ` console — which only exists once firmware is already on the board. So a student
5
+ holding a *virgin* board could not start from gBuilder at all: they needed a terminal,
6
+ a 2 GB ESP-IDF install and the `gini32` CLI. Flashing is the missing first step.
7
+
8
+ **Why flashing can live here but building cannot.** Building needs ESP-IDF: the whole
9
+ toolchain, `export.sh` sourced into the environment, a per-chip `set-target`, and network
10
+ access for the managed components. That is an instructor-grade install and no UI hides
11
+ it. Flashing needs only `esptool` — pure Python, no toolchain — plus ~870 KB of prebuilt
12
+ images. It is also the right split for the architecture: the firmware is built ONCE and
13
+ is identical on every board, so building is a rare developer act while flashing is a
14
+ routine student one.
15
+
16
+ **The offsets are load-bearing — do not merge the images.** The flash layout is::
17
+
18
+ 0x00000 bootloader.bin
19
+ 0x08000 partition-table.bin
20
+ 0x09000 nvs <-- the board's identity: id, lab Wi-Fi, owner, LED pin
21
+ 0x10000 gbridge.bin (the app)
22
+
23
+ `esptool merge_bin` would produce one image from 0x0 and pad the gaps with 0xFF, which
24
+ runs straight over NVS at 0x9000 and silently unpairs the board, forgets its id and
25
+ forgets the lab Wi-Fi. Writing the three files at their own offsets leaves 0x9000
26
+ untouched, so re-flashing a board that is already set up keeps it set up. For the same
27
+ reason there is deliberately no `erase_flash` here.
28
+
29
+ Standard library plus `esptool`, invoked as a subprocess rather than imported: esptool's
30
+ Python API has changed shape across major versions, while its command line has been
31
+ stable for years, and a subprocess cannot take the GUI down with it.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ import re
37
+ import subprocess
38
+ import sys
39
+ from dataclasses import dataclass, field
40
+ from pathlib import Path
41
+
42
+ # (offset, filename) — see the module docstring on why these are separate writes.
43
+ IMAGES: tuple[tuple[int, str], ...] = (
44
+ (0x0000, "bootloader.bin"),
45
+ (0x8000, "partition-table.bin"),
46
+ (0x10000, "gbridge.bin"),
47
+ )
48
+
49
+ # NVS lives here. Nothing in this module may write at or across it.
50
+ NVS_OFFSET = 0x9000
51
+
52
+ # esptool reports chips in human form ("ESP32-S3"); the build tree names them the way
53
+ # `idf.py set-target` does ("esp32s3"). One canonical direction, computed not hardcoded.
54
+ def _canonical_target(chip: str) -> str:
55
+ return re.sub(r"[^a-z0-9]", "", (chip or "").strip().lower())
56
+
57
+
58
+ def firmware_root() -> Path:
59
+ """Where prebuilt images live: ``<repo>/backend/gini32/firmware``.
60
+
61
+ Same repo-relative convention the orchestrator uses to find the backend, so a source
62
+ checkout works with no configuration. Override with GINI_FIRMWARE_DIR (a packaged
63
+ build, or an instructor pointing a lab at a freshly built image).
64
+ """
65
+ env = os.environ.get("GINI_FIRMWARE_DIR")
66
+ if env:
67
+ return Path(env).expanduser()
68
+ return Path(__file__).resolve().parents[4] / "backend" / "gini32" / "firmware"
69
+
70
+
71
+ @dataclass
72
+ class Firmware:
73
+ """A complete, flashable set of images for one chip."""
74
+ target: str
75
+ directory: Path
76
+ files: list[tuple[int, Path]] = field(default_factory=list)
77
+ build: str = "" # the GB_BUILD marker read out of the app image
78
+
79
+ @property
80
+ def total_bytes(self) -> int:
81
+ return sum(p.stat().st_size for _, p in self.files if p.exists())
82
+
83
+
84
+ def read_build_marker(app_bin: Path) -> str:
85
+ """Pull the GB_BUILD string out of a built app image.
86
+
87
+ A shipped binary can drift from the source beside it, and a stale flash masquerading
88
+ as a fresh one has already cost this project two debugging sessions. The firmware
89
+ stamps GB_BUILD into its boot log; finding the same string here lets the UI say
90
+ exactly what it is about to install, and lets a human compare the two.
91
+ """
92
+ try:
93
+ blob = app_bin.read_bytes()
94
+ except OSError:
95
+ return ""
96
+ m = re.search(rb"gbridge-\d+ \([^)]{0,40}\)", blob)
97
+ return m.group(0).decode("ascii", "replace") if m else ""
98
+
99
+
100
+ def available(target: str, root: Path | None = None) -> Firmware | None:
101
+ """The flashable image set for `target`, or None if it is not shipped."""
102
+ root = root or firmware_root()
103
+ d = root / _canonical_target(target)
104
+ if not d.is_dir():
105
+ return None
106
+ files = [(off, d / name) for off, name in IMAGES]
107
+ if not all(p.is_file() for _, p in files):
108
+ return None # a partial set is worse than none
109
+ app = d / IMAGES[-1][1]
110
+ return Firmware(target=_canonical_target(target), directory=d, files=files,
111
+ build=read_build_marker(app))
112
+
113
+
114
+ def available_targets(root: Path | None = None) -> list[str]:
115
+ root = root or firmware_root()
116
+ if not root.is_dir():
117
+ return []
118
+ return sorted(d.name for d in root.iterdir()
119
+ if d.is_dir() and available(d.name, root) is not None)
120
+
121
+
122
+ # --------------------------------------------------------------------- esptool
123
+
124
+ def esptool_argv() -> list[str]:
125
+ """How to invoke esptool. ``-m esptool`` uses the SAME interpreter running gBuilder,
126
+ so a virtualenv install is found without depending on PATH."""
127
+ return [sys.executable, "-m", "esptool"]
128
+
129
+
130
+ def esptool_available(run=None) -> bool:
131
+ run = run or _run
132
+ try:
133
+ rc, _ = run(esptool_argv() + ["version"], timeout=20)
134
+ return rc == 0
135
+ except (OSError, subprocess.SubprocessError):
136
+ return False
137
+
138
+
139
+ def _run(argv: list[str], timeout: float = 300.0) -> tuple[int, str]:
140
+ p = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
141
+ timeout=timeout)
142
+ return p.returncode, p.stdout.decode("utf-8", "replace")
143
+
144
+
145
+ _CHIP_RE = re.compile(r"(ESP32(?:-[A-Z0-9]+)?)\b")
146
+
147
+
148
+ def detect_chip(port: str, run=None) -> str:
149
+ """Ask the board what it is, so we flash an image built for that chip.
150
+
151
+ Returns a canonical target ("esp32s3") or "" if nothing answered. Flashing an image
152
+ built for the wrong chip produces a board that is bricked-looking rather than
153
+ obviously wrong — it boots into a reset loop with no console — so this is checked
154
+ rather than assumed.
155
+ """
156
+ run = run or _run
157
+ try:
158
+ rc, out = run(esptool_argv() + ["--port", port, "chip_id"], timeout=60)
159
+ except (OSError, subprocess.SubprocessError):
160
+ return ""
161
+ if rc != 0:
162
+ return ""
163
+ # "Detecting chip type... ESP32-S3" / "Chip is ESP32-S3 (revision v0.2)"
164
+ for line in out.splitlines():
165
+ if "chip" not in line.lower():
166
+ continue
167
+ m = _CHIP_RE.search(line)
168
+ if m:
169
+ return _canonical_target(m.group(1))
170
+ return ""
171
+
172
+
173
+ @dataclass
174
+ class FlashResult:
175
+ ok: bool
176
+ message: str
177
+ output: str = ""
178
+ build: str = ""
179
+
180
+
181
+ def flash(port: str, firmware: Firmware, baud: int = 460800,
182
+ run=None, on_progress=None) -> FlashResult:
183
+ """Write the three images at their own offsets. NVS is not touched.
184
+
185
+ `on_progress` is called with short human sentences; the caller decides where they go.
186
+ """
187
+ run = run or _run
188
+ say = on_progress or (lambda _m: None)
189
+
190
+ missing = [str(p) for _, p in firmware.files if not p.is_file()]
191
+ if missing:
192
+ return FlashResult(False, f"firmware image missing: {', '.join(missing)}")
193
+
194
+ # Belt and braces: if anyone ever edits IMAGES, catch an overlap with NVS here
195
+ # rather than discovering it as boards mysteriously forgetting who they are.
196
+ for off, path in firmware.files:
197
+ size = path.stat().st_size
198
+ if off < NVS_OFFSET < off + size:
199
+ return FlashResult(
200
+ False,
201
+ f"{path.name} at 0x{off:x} would run over NVS at 0x{NVS_OFFSET:x} — "
202
+ f"that would erase the board's identity, so refusing to flash.")
203
+
204
+ argv = esptool_argv() + ["--chip", firmware.target, "--port", port,
205
+ "--baud", str(baud), "write_flash"]
206
+ for off, path in firmware.files:
207
+ argv += [hex(off), str(path)]
208
+
209
+ say(f"writing {firmware.total_bytes // 1024} KB to {port} …")
210
+ try:
211
+ rc, out = run(argv, timeout=600)
212
+ except subprocess.TimeoutExpired:
213
+ return FlashResult(False, "esptool did not finish — is the board still plugged in?")
214
+ except FileNotFoundError:
215
+ return FlashResult(False, "esptool is not installed (pip install esptool)")
216
+ except (OSError, subprocess.SubprocessError) as exc:
217
+ return FlashResult(False, f"could not run esptool: {exc}")
218
+
219
+ if rc != 0:
220
+ return FlashResult(False, _explain_failure(out), output=out)
221
+ return FlashResult(True, f"flashed {firmware.build or 'firmware'} — "
222
+ f"the board keeps its id and lab Wi-Fi",
223
+ output=out, build=firmware.build)
224
+
225
+
226
+ _HINTS = (
227
+ ("Failed to connect", "the board did not answer. Hold BOOT, tap RESET, release BOOT, "
228
+ "then try again — some boards cannot be reset over USB alone."),
229
+ ("Permission denied", "no permission for the serial port. On Linux: "
230
+ "`sudo usermod -aG dialout $USER`, then log out and back in."),
231
+ ("could not open port", "the port is busy — close any serial monitor "
232
+ "(including gBuilder's own Set Up a Board) and retry."),
233
+ ("Resource busy", "the port is busy — close any serial monitor and retry."),
234
+ ("does not match", "this image was built for a different chip than the board reports."),
235
+ )
236
+
237
+
238
+ def _explain_failure(out: str) -> str:
239
+ """Turn esptool's output into one sentence a student can act on.
240
+
241
+ Every failure here is physical — a cable, a button, a permission — and the raw output
242
+ buries that under a stack trace.
243
+ """
244
+ for needle, hint in _HINTS:
245
+ if needle.lower() in out.lower():
246
+ return hint
247
+ tail = [l for l in out.strip().splitlines() if l.strip()]
248
+ return tail[-1] if tail else "esptool failed with no output"