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,370 @@
1
+ """GINI Cloud Fabric agent — the cloud telemetry & coordination plane.
2
+
3
+ One small container watches every cloud service in the lab. It reads FABRIC_CONFIG (the
4
+ list of services + how to reach them), and on a timer asks each one for its *native*
5
+ application metrics through a per-type adapter — Redis INFO, the RabbitMQ management API,
6
+ Postgres pg_stat, Traefik / NATS / nginx endpoints. It normalizes everything to one
7
+ schema and serves it at ``/metrics.json`` for gBuilder to poll (and ``/metrics`` in
8
+ Prometheus text so Grafana can graph the app metrics too).
9
+
10
+ Stdlib-only at import time (urllib + raw sockets); psycopg2 is imported lazily inside the
11
+ Postgres adapter, so the parse helpers below are safe to unit-test anywhere.
12
+
13
+ FABRIC_CONFIG = {"services":[
14
+ {"name":"redis1","type":"cache","host":"redis1","port":6379,"creds":{}},
15
+ {"name":"rabbit1","type":"queue","host":"rabbit1","port":15672,
16
+ "creds":{"user":"guest","password":"guest"}}, ... ]}
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import socket
23
+ import time
24
+ import urllib.error
25
+ import urllib.request
26
+
27
+ POLL = float(os.environ.get("FABRIC_POLL", "2.0"))
28
+ PORT = int(os.environ.get("FABRIC_PORT", "9099"))
29
+
30
+
31
+ # --------------------------------------------------------------------------- #
32
+ # Pure parse helpers (unit-tested — no network)
33
+ # --------------------------------------------------------------------------- #
34
+ def parse_prometheus(text: str) -> dict[str, float]:
35
+ """A Prometheus text exposition -> {metric_name: summed_value}. Labels are ignored
36
+ (samples for the same metric name are summed), which is what we want for totals."""
37
+ out: dict[str, float] = {}
38
+ for line in (text or "").splitlines():
39
+ line = line.strip()
40
+ if not line or line.startswith("#"):
41
+ continue
42
+ # name{labels} value OR name value
43
+ name = line.split("{", 1)[0].split(" ", 1)[0]
44
+ val = line.rsplit(" ", 1)[-1]
45
+ try:
46
+ out[name] = out.get(name, 0.0) + float(val)
47
+ except ValueError:
48
+ continue
49
+ return out
50
+
51
+
52
+ def parse_redis_info(text: str) -> dict[str, str]:
53
+ """Redis INFO 'key:value' lines -> dict."""
54
+ out: dict[str, str] = {}
55
+ for line in (text or "").splitlines():
56
+ line = line.strip()
57
+ if not line or line.startswith("#") or ":" not in line:
58
+ continue
59
+ k, _, v = line.partition(":")
60
+ out[k.strip()] = v.strip()
61
+ return out
62
+
63
+
64
+ def redis_kpis(info: dict) -> list[dict]:
65
+ def num(k, d=0.0):
66
+ try:
67
+ return float(info.get(k, d))
68
+ except (TypeError, ValueError):
69
+ return d
70
+ hits, misses = num("keyspace_hits"), num("keyspace_misses")
71
+ hit_rate = 100.0 * hits / (hits + misses) if (hits + misses) > 0 else 0.0
72
+ return [
73
+ {"label": "ops", "value": round(num("instantaneous_ops_per_sec"), 1), "unit": "/s"},
74
+ {"label": "hit rate", "value": round(hit_rate, 1), "unit": "%"},
75
+ {"label": "clients", "value": int(num("connected_clients")), "unit": ""},
76
+ {"label": "memory", "value": round(num("used_memory") / 1048576, 1), "unit": "MiB"},
77
+ ]
78
+
79
+
80
+ def rabbit_kpis(overview: dict) -> list[dict]:
81
+ qt = overview.get("queue_totals", {}) or {}
82
+ ms = overview.get("message_stats", {}) or {}
83
+ pub = (ms.get("publish_details") or {}).get("rate", 0.0)
84
+ dlv = (ms.get("deliver_get_details") or ms.get("deliver_details") or {}).get("rate", 0.0)
85
+ return [
86
+ {"label": "queued", "value": int(qt.get("messages", 0)), "unit": "msgs"},
87
+ {"label": "publish", "value": round(float(pub), 1), "unit": "/s"},
88
+ {"label": "deliver", "value": round(float(dlv), 1), "unit": "/s"},
89
+ ]
90
+
91
+
92
+ def nats_kpis(varz: dict, rates, ts: float, key: str) -> list[dict]:
93
+ inm = float(varz.get("in_msgs", 0)); outm = float(varz.get("out_msgs", 0))
94
+ return [
95
+ {"label": "conns", "value": int(varz.get("connections", 0)), "unit": ""},
96
+ {"label": "in", "value": round(rates.rate(key + ":in", inm, ts), 1), "unit": "msg/s"},
97
+ {"label": "out", "value": round(rates.rate(key + ":out", outm, ts), 1), "unit": "msg/s"},
98
+ ]
99
+
100
+
101
+ def parse_nginx_status(text: str) -> dict[str, float]:
102
+ """nginx stub_status -> {active, accepts, handled, requests}. The three counters are
103
+ on the line *after* the 'server accepts handled requests' header."""
104
+ out: dict[str, float] = {}
105
+ lines = (text or "").splitlines()
106
+ for i, line in enumerate(lines):
107
+ if "Active connections:" in line:
108
+ try:
109
+ out["active"] = float(line.split(":")[1].strip().split()[0])
110
+ except (IndexError, ValueError):
111
+ pass
112
+ if "accepts" in line and "handled" in line and "requests" in line:
113
+ nums = [t for t in lines[i + 1].split() if t.isdigit()] if i + 1 < len(lines) else []
114
+ if len(nums) >= 3:
115
+ out["accepts"], out["handled"], out["requests"] = map(float, nums[:3])
116
+ return out
117
+
118
+
119
+ # --------------------------------------------------------------------------- #
120
+ # Rate tracker (counter -> per-second), and fetch helpers
121
+ # --------------------------------------------------------------------------- #
122
+ class Rates:
123
+ def __init__(self) -> None:
124
+ self._prev: dict[str, tuple[float, float]] = {}
125
+
126
+ def rate(self, key: str, value: float, ts: float) -> float:
127
+ prev = self._prev.get(key)
128
+ self._prev[key] = (value, ts)
129
+ if not prev:
130
+ return 0.0
131
+ pv, pt = prev
132
+ dt = ts - pt
133
+ return max(0.0, (value - pv) / dt) if dt > 0 else 0.0
134
+
135
+
136
+ def _http_get(url: str, timeout: float = 3.0, auth: tuple | None = None) -> str | None:
137
+ req = urllib.request.Request(url)
138
+ if auth:
139
+ import base64
140
+ tok = base64.b64encode(f"{auth[0]}:{auth[1]}".encode()).decode()
141
+ req.add_header("Authorization", "Basic " + tok)
142
+ try:
143
+ with urllib.request.urlopen(req, timeout=timeout) as r:
144
+ return r.read().decode("utf-8", "replace")
145
+ except (urllib.error.URLError, OSError, ValueError):
146
+ return None
147
+
148
+
149
+ def _tcp_up(host: str, port: int, timeout: float = 2.0) -> bool:
150
+ try:
151
+ with socket.create_connection((host, int(port)), timeout=timeout):
152
+ return True
153
+ except OSError:
154
+ return False
155
+
156
+
157
+ def _redis_info_raw(host: str, port: int, timeout: float = 2.0) -> str | None:
158
+ try:
159
+ with socket.create_connection((host, int(port)), timeout=timeout) as s:
160
+ s.sendall(b"INFO\r\n")
161
+ s.settimeout(timeout)
162
+ chunks = []
163
+ while True:
164
+ b = s.recv(8192)
165
+ if not b:
166
+ break
167
+ chunks.append(b)
168
+ if len(b) < 8192:
169
+ break
170
+ return b"".join(chunks).decode("utf-8", "replace")
171
+ except OSError:
172
+ return None
173
+
174
+
175
+ # --------------------------------------------------------------------------- #
176
+ # Adapters: type -> collect(svc, rates, ts) -> {"up", "kpis", [rps], [latency_ms]}
177
+ # --------------------------------------------------------------------------- #
178
+ def collect_cache(svc, rates, ts):
179
+ raw = _redis_info_raw(svc["host"], svc.get("port", 6379))
180
+ if raw is None:
181
+ return {"up": _tcp_up(svc["host"], svc.get("port", 6379)), "kpis": []}
182
+ return {"up": True, "kpis": redis_kpis(parse_redis_info(raw))}
183
+
184
+
185
+ def collect_queue(svc, rates, ts):
186
+ c = svc.get("creds", {})
187
+ url = f"http://{svc['host']}:{svc.get('port', 15672)}/api/overview"
188
+ txt = _http_get(url, auth=(c.get("user", "guest"), c.get("password", "guest")))
189
+ if txt is None:
190
+ return {"up": _tcp_up(svc["host"], 5672), "kpis": []}
191
+ try:
192
+ return {"up": True, "kpis": rabbit_kpis(json.loads(txt))}
193
+ except (ValueError, KeyError):
194
+ return {"up": True, "kpis": []}
195
+
196
+
197
+ def collect_database(svc, rates, ts):
198
+ host, port = svc["host"], svc.get("port", 5432)
199
+ c = svc.get("creds", {})
200
+ try:
201
+ import psycopg2 # lazy: only this adapter needs it
202
+ conn = psycopg2.connect(host=host, port=port, connect_timeout=2,
203
+ user=c.get("user", "gini"), password=c.get("password", "gini"),
204
+ dbname=c.get("db", "postgres"))
205
+ try:
206
+ cur = conn.cursor()
207
+ cur.execute("SELECT COALESCE(sum(xact_commit),0), COALESCE(sum(numbackends),0) "
208
+ "FROM pg_stat_database")
209
+ commits, conns = cur.fetchone()
210
+ finally:
211
+ conn.close()
212
+ return {"up": True, "kpis": [
213
+ {"label": "TPS", "value": round(rates.rate(svc["name"] + ":tx",
214
+ float(commits), ts), 1), "unit": "/s"},
215
+ {"label": "connections", "value": int(conns), "unit": ""}]}
216
+ except Exception: # noqa: BLE001 — driver missing or unreachable
217
+ return {"up": _tcp_up(host, port), "kpis": []}
218
+
219
+
220
+ def collect_proxy(svc, rates, ts):
221
+ """Traefik exposes Prometheus metrics at :8080/metrics when started with
222
+ --metrics.prometheus. req/s from the request counter, latency from the sum/count."""
223
+ txt = _http_get(f"http://{svc['host']}:{svc.get('port', 8080)}/metrics")
224
+ if txt is None:
225
+ return {"up": _tcp_up(svc["host"], svc.get("port", 8080)), "kpis": []}
226
+ m = parse_prometheus(txt)
227
+ reqs = sum(v for k, v in m.items() if k.endswith("requests_total"))
228
+ rps = rates.rate(svc["name"] + ":req", reqs, ts)
229
+ kpis = [{"label": "req", "value": round(rps, 1), "unit": "/s"}]
230
+ # average latency over the interval = Δ(duration_sum) / Δ(request_count)
231
+ dur = sum(v for k, v in m.items() if k.endswith("request_duration_seconds_sum"))
232
+ cnt = sum(v for k, v in m.items() if k.endswith("request_duration_seconds_count"))
233
+ d_dur = rates.rate(svc["name"] + ":durs", dur, ts)
234
+ d_cnt = rates.rate(svc["name"] + ":durc", cnt, ts)
235
+ out = {"up": True, "rps": round(rps, 1)}
236
+ if d_cnt > 0:
237
+ ms = 1000.0 * d_dur / d_cnt
238
+ kpis.append({"label": "latency", "value": round(ms, 1), "unit": "ms"})
239
+ out["latency_ms"] = round(ms, 1)
240
+ out["kpis"] = kpis
241
+ return out
242
+
243
+
244
+ def collect_loadbalancer(svc, rates, ts):
245
+ """nginx stub_status at /nginx_status (enabled via a generated conf)."""
246
+ txt = _http_get(f"http://{svc['host']}:{svc.get('port', 80)}/nginx_status")
247
+ if txt is None:
248
+ return {"up": _tcp_up(svc["host"], svc.get("port", 80)), "kpis": []}
249
+ st = parse_nginx_status(txt)
250
+ rps = rates.rate(svc["name"] + ":req", st.get("requests", 0.0), ts)
251
+ return {"up": True, "rps": round(rps, 1), "kpis": [
252
+ {"label": "req", "value": round(rps, 1), "unit": "/s"},
253
+ {"label": "active", "value": int(st.get("active", 0)), "unit": ""}]}
254
+
255
+
256
+ def collect_nats(svc, rates, ts):
257
+ txt = _http_get(f"http://{svc['host']}:{svc.get('port', 8222)}/varz")
258
+ if txt is None:
259
+ return {"up": _tcp_up(svc["host"], 4222), "kpis": []}
260
+ try:
261
+ return {"up": True, "kpis": nats_kpis(json.loads(txt), rates, ts, svc["name"])}
262
+ except ValueError:
263
+ return {"up": True, "kpis": []}
264
+
265
+
266
+ def collect_generic(svc, rates, ts):
267
+ """Anything without a dedicated adapter: just a reachability check (CPU/mem still
268
+ come from docker stats in gBuilder's Live view)."""
269
+ return {"up": _tcp_up(svc["host"], svc.get("port", 80)), "kpis": []}
270
+
271
+
272
+ ADAPTERS = {
273
+ "cache": collect_cache,
274
+ "queue": collect_queue,
275
+ "database": collect_database,
276
+ "proxy": collect_proxy,
277
+ "load_balancer": collect_loadbalancer,
278
+ "messaging": collect_nats,
279
+ }
280
+
281
+
282
+ def snapshot(config: dict, rates: Rates) -> dict:
283
+ ts = time.time()
284
+ services: dict[str, dict] = {}
285
+ up = 0
286
+ total_rps = 0.0
287
+ max_lat = 0.0
288
+ for svc in config.get("services", []):
289
+ fn = ADAPTERS.get(svc.get("type"), collect_generic)
290
+ try:
291
+ data = fn(svc, rates, ts)
292
+ except Exception as e: # noqa: BLE001
293
+ data = {"up": False, "kpis": [], "error": str(e)}
294
+ kpis = data.get("kpis", [])
295
+ services[svc["name"]] = {
296
+ "type": svc.get("type"), "up": bool(data.get("up")),
297
+ "kpis": kpis, "primary": kpis[0] if kpis else None}
298
+ if data.get("up"):
299
+ up += 1
300
+ if data.get("rps"):
301
+ total_rps += float(data["rps"])
302
+ if data.get("latency_ms"):
303
+ max_lat = max(max_lat, float(data["latency_ms"]))
304
+ return {"ts": ts, "services": services,
305
+ "totals": {"services_up": up, "services_total": len(config.get("services", [])),
306
+ "rps": round(total_rps, 1), "latency_ms": round(max_lat, 1)}}
307
+
308
+
309
+ # --------------------------------------------------------------------------- #
310
+ # HTTP server + poll loop
311
+ # --------------------------------------------------------------------------- #
312
+ _LATEST: dict = {"ts": 0, "services": {}, "totals": {}}
313
+
314
+
315
+ def _poll_loop(config: dict) -> None:
316
+ rates = Rates()
317
+ global _LATEST
318
+ while True:
319
+ try:
320
+ _LATEST = snapshot(config, rates)
321
+ except Exception as e: # noqa: BLE001
322
+ _LATEST = {"ts": time.time(), "services": {}, "totals": {}, "error": str(e)}
323
+ time.sleep(POLL)
324
+
325
+
326
+ def _to_prometheus(snap: dict) -> str:
327
+ lines = []
328
+ for name, s in snap.get("services", {}).items():
329
+ lines.append(f'gini_service_up{{service="{name}",type="{s.get("type")}"}} '
330
+ f'{1 if s.get("up") else 0}')
331
+ for k in s.get("kpis", []):
332
+ metric = "gini_" + str(k["label"]).replace(" ", "_")
333
+ lines.append(f'{metric}{{service="{name}"}} {k["value"]}')
334
+ return "\n".join(lines) + "\n"
335
+
336
+
337
+ def main() -> None:
338
+ import threading
339
+ from http.server import BaseHTTPRequestHandler, HTTPServer
340
+
341
+ config = json.loads(os.environ.get("FABRIC_CONFIG", '{"services":[]}'))
342
+ threading.Thread(target=_poll_loop, args=(config,), daemon=True).start()
343
+
344
+ class Handler(BaseHTTPRequestHandler):
345
+ def log_message(self, *_a): # quiet
346
+ pass
347
+
348
+ def do_GET(self):
349
+ if self.path.startswith("/metrics.json"):
350
+ body = json.dumps(_LATEST).encode()
351
+ ctype = "application/json"
352
+ elif self.path.startswith("/metrics"):
353
+ body = _to_prometheus(_LATEST).encode()
354
+ ctype = "text/plain; version=0.0.4"
355
+ else:
356
+ body = b'gini cloud fabric: /metrics.json or /metrics\n'
357
+ ctype = "text/plain"
358
+ self.send_response(200)
359
+ self.send_header("Content-Type", ctype)
360
+ self.send_header("Content-Length", str(len(body)))
361
+ self.end_headers()
362
+ self.wfile.write(body)
363
+
364
+ print(f"[cloudfabric] watching {len(config.get('services', []))} services on :{PORT}",
365
+ flush=True)
366
+ HTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
367
+
368
+
369
+ if __name__ == "__main__":
370
+ main()
@@ -0,0 +1,68 @@
1
+ """Interactive console client for one network element.
2
+
3
+ Runs inside the fabric container: python -m dataplane.console R1
4
+ Connects to that element's control socket and relays stdin<->socket, giving the
5
+ student an interactive prompt scoped to *that* router/switch.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import select
11
+ import socket
12
+ import sys
13
+ import time
14
+
15
+
16
+ def main() -> int: # pragma: no cover - interactive / needs container
17
+ if len(sys.argv) < 2:
18
+ print("usage: python -m dataplane.console <element-name> [command]")
19
+ return 2
20
+ name = sys.argv[1]
21
+ oneshot = " ".join(sys.argv[2:]).strip() if len(sys.argv) > 2 else None
22
+ ctrl_dir = os.environ.get("GINI_CTRL_DIR", "/run/gini")
23
+ path = os.path.join(ctrl_dir, f"{name}.sock")
24
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
25
+ try:
26
+ s.connect(path)
27
+ except OSError as e:
28
+ print(f"cannot connect to {name} ({path}): {e}")
29
+ return 1
30
+
31
+ if oneshot is not None: # non-interactive: one command, print, exit
32
+ try:
33
+ s.recv(4096) # drain banner + prompt
34
+ s.sendall((oneshot + "\n").encode())
35
+ time.sleep(0.2)
36
+ s.settimeout(0.6)
37
+ data = b""
38
+ while True:
39
+ chunk = s.recv(4096)
40
+ if not chunk:
41
+ break
42
+ data += chunk
43
+ if data.rstrip().endswith(b"gini>"):
44
+ break
45
+ except OSError:
46
+ pass
47
+ print(data.decode(errors="replace").replace("gini>", "").strip())
48
+ return 0
49
+
50
+ print(f"-- GINI console: {name} (type 'help', 'exit') --")
51
+ while True:
52
+ r, _, _ = select.select([s, sys.stdin], [], [])
53
+ if s in r:
54
+ data = s.recv(4096)
55
+ if not data:
56
+ break
57
+ sys.stdout.write(data.decode(errors="replace"))
58
+ sys.stdout.flush()
59
+ if sys.stdin in r:
60
+ line = sys.stdin.readline()
61
+ if not line:
62
+ break
63
+ s.sendall(line.encode())
64
+ return 0
65
+
66
+
67
+ if __name__ == "__main__": # pragma: no cover
68
+ raise SystemExit(main())
@@ -0,0 +1,70 @@
1
+ """Per-element control socket — the seed of the gRouter/switch console.
2
+
3
+ Each switch and gRouter, when GINI_CTRL_DIR is set, listens on its own UNIX socket
4
+ (<dir>/<name>.sock) speaking a tiny line protocol. `console.py` connects to one of
5
+ these, so a student can "log into" R1 or S1 *individually* even though they share the
6
+ fabric container. This is the first slice of the R3 control protocol.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import socket
12
+ import threading
13
+ from collections.abc import Callable
14
+
15
+
16
+ class ControlServer(threading.Thread):
17
+ def __init__(self, path: str, handler: Callable[[str], str], banner: str = "") -> None:
18
+ super().__init__(daemon=True)
19
+ self.path = path
20
+ self.handler = handler
21
+ self.banner = banner
22
+
23
+ def run(self) -> None: # pragma: no cover - exercised via integration/in-process test
24
+ try:
25
+ os.unlink(self.path)
26
+ except OSError:
27
+ pass
28
+ srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
29
+ srv.bind(self.path)
30
+ srv.listen()
31
+ while True:
32
+ conn, _ = srv.accept()
33
+ threading.Thread(target=self._serve, args=(conn,), daemon=True).start()
34
+
35
+ def _serve(self, conn: socket.socket) -> None:
36
+ conn.sendall((self.banner + "\ngini> ").encode())
37
+ buf = b""
38
+ while True:
39
+ try:
40
+ data = conn.recv(1024)
41
+ except OSError:
42
+ break
43
+ if not data:
44
+ break
45
+ buf += data
46
+ while b"\n" in buf:
47
+ line, buf = buf.split(b"\n", 1)
48
+ cmd = line.decode(errors="replace").strip()
49
+ if cmd in ("exit", "quit"):
50
+ conn.close()
51
+ return
52
+ out = ""
53
+ if cmd:
54
+ try:
55
+ out = self.handler(cmd)
56
+ except Exception as e: # never kill the console on a bad command
57
+ out = f"error: {e}"
58
+ conn.sendall((out + "\ngini> ").encode())
59
+ conn.close()
60
+
61
+
62
+ def maybe_start(name: str, handler: Callable[[str], str], banner: str = "") -> ControlServer | None:
63
+ """Start a control server iff GINI_CTRL_DIR is set (i.e., running in the fabric)."""
64
+ ctrl_dir = os.environ.get("GINI_CTRL_DIR")
65
+ if not ctrl_dir:
66
+ return None
67
+ os.makedirs(ctrl_dir, exist_ok=True)
68
+ server = ControlServer(os.path.join(ctrl_dir, f"{name}.sock"), handler, banner)
69
+ server.start()
70
+ return server
gini/runtime/frame.py ADDED
@@ -0,0 +1,138 @@
1
+ """Ethernet / ARP / IPv4 / ICMP frame helpers.
2
+
3
+ Deliberately tiny and dependency-free (stdlib only) so the same code runs inside a
4
+ slim container and in the no-Docker loopback test. Addresses are strings at the API
5
+ boundary (MAC "aa:bb:..", IP "1.2.3.4"); bytes only inside.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import socket
10
+ import struct
11
+
12
+ # ethertypes
13
+ ETH_IP = 0x0800
14
+ ETH_ARP = 0x0806
15
+ BROADCAST = "ff:ff:ff:ff:ff:ff"
16
+ ZERO_MAC = "00:00:00:00:00:00"
17
+
18
+ # ip protocols
19
+ PROTO_ICMP = 1
20
+
21
+ # icmp types
22
+ ICMP_ECHO_REQUEST = 8
23
+ ICMP_ECHO_REPLY = 0
24
+
25
+
26
+ # -- address conversion ----------------------------------------------------- #
27
+ def mac_to_bytes(m: str) -> bytes:
28
+ return bytes(int(x, 16) for x in m.split(":"))
29
+
30
+
31
+ def mac_to_str(b: bytes) -> str:
32
+ return ":".join("%02x" % x for x in b)
33
+
34
+
35
+ def ip_to_bytes(s: str) -> bytes:
36
+ return socket.inet_aton(s)
37
+
38
+
39
+ def ip_to_str(b: bytes) -> str:
40
+ return socket.inet_ntoa(b)
41
+
42
+
43
+ def is_multicast_mac(m: str) -> bool:
44
+ """Broadcast and multicast both have the low bit of the first octet set."""
45
+ return bool(int(m.split(":")[0], 16) & 1)
46
+
47
+
48
+ # -- internet checksum ------------------------------------------------------ #
49
+ def checksum(data: bytes) -> int:
50
+ if len(data) % 2:
51
+ data += b"\x00"
52
+ s = sum(int.from_bytes(data[i:i + 2], "big") for i in range(0, len(data), 2))
53
+ s = (s & 0xFFFF) + (s >> 16)
54
+ s = (s & 0xFFFF) + (s >> 16)
55
+ return (~s) & 0xFFFF
56
+
57
+
58
+ # -- ethernet --------------------------------------------------------------- #
59
+ def build_eth(dst: str, src: str, etype: int, payload: bytes) -> bytes:
60
+ return mac_to_bytes(dst) + mac_to_bytes(src) + struct.pack("!H", etype) + payload
61
+
62
+
63
+ def parse_eth(frame: bytes):
64
+ dst = mac_to_str(frame[0:6])
65
+ src = mac_to_str(frame[6:12])
66
+ etype = struct.unpack("!H", frame[12:14])[0]
67
+ return dst, src, etype, frame[14:]
68
+
69
+
70
+ # -- arp -------------------------------------------------------------------- #
71
+ def build_arp(op: int, sha: str, spa: str, tha: str, tpa: str) -> bytes:
72
+ return (struct.pack("!HHBBH", 1, ETH_IP, 6, 4, op)
73
+ + mac_to_bytes(sha) + ip_to_bytes(spa)
74
+ + mac_to_bytes(tha) + ip_to_bytes(tpa))
75
+
76
+
77
+ def parse_arp(p: bytes):
78
+ op = struct.unpack("!H", p[6:8])[0]
79
+ sha = mac_to_str(p[8:14])
80
+ spa = ip_to_str(p[14:18])
81
+ tha = mac_to_str(p[18:24])
82
+ tpa = ip_to_str(p[24:28])
83
+ return op, sha, spa, tha, tpa
84
+
85
+
86
+ # -- ipv4 ------------------------------------------------------------------- #
87
+ def build_ipv4(src: str, dst: str, proto: int, payload: bytes,
88
+ ttl: int = 64, ident: int = 0) -> bytes:
89
+ total = 20 + len(payload)
90
+ fields = (0x45, 0, total, ident, 0, ttl, proto, 0,
91
+ ip_to_bytes(src), ip_to_bytes(dst))
92
+ hdr = struct.pack("!BBHHHBBH4s4s", *fields)
93
+ csum = checksum(hdr)
94
+ fields = (0x45, 0, total, ident, 0, ttl, proto, csum,
95
+ ip_to_bytes(src), ip_to_bytes(dst))
96
+ hdr = struct.pack("!BBHHHBBH4s4s", *fields)
97
+ return hdr + payload
98
+
99
+
100
+ def parse_ipv4(p: bytes) -> dict:
101
+ ihl = (p[0] & 0x0F) * 4
102
+ total = struct.unpack("!H", p[2:4])[0]
103
+ return {
104
+ "ihl": ihl,
105
+ "ttl": p[8],
106
+ "proto": p[9],
107
+ "src": ip_to_str(p[12:16]),
108
+ "dst": ip_to_str(p[16:20]),
109
+ "payload": p[ihl:total] if total <= len(p) else p[ihl:],
110
+ }
111
+
112
+
113
+ def dec_ttl(ippkt: bytes) -> bytes | None:
114
+ """Decrement TTL and fix the header checksum. None if TTL would hit 0."""
115
+ b = bytearray(ippkt)
116
+ ihl = (b[0] & 0x0F) * 4
117
+ if b[8] <= 1:
118
+ return None
119
+ b[8] -= 1
120
+ b[10] = 0
121
+ b[11] = 0
122
+ c = checksum(bytes(b[:ihl]))
123
+ b[10] = c >> 8
124
+ b[11] = c & 0xFF
125
+ return bytes(b)
126
+
127
+
128
+ # -- icmp ------------------------------------------------------------------- #
129
+ def build_icmp(typ: int, ident: int, seq: int, data: bytes = b"gini-r0-spike!!!") -> bytes:
130
+ hdr = struct.pack("!BBHHH", typ, 0, 0, ident, seq)
131
+ csum = checksum(hdr + data)
132
+ hdr = struct.pack("!BBHHH", typ, 0, csum, ident, seq)
133
+ return hdr + data
134
+
135
+
136
+ def parse_icmp(p: bytes):
137
+ typ, _code, _csum, ident, seq = struct.unpack("!BBHHH", p[:8])
138
+ return typ, ident, seq, p[8:]