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