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