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,141 @@
|
|
|
1
|
+
"""Page-replacement simulator — the real algorithms (FIFO / LRU / Optimal) run over a reference
|
|
2
|
+
string with a bounded frame budget. Deterministic and exact, so it is ground truth for the thrashing
|
|
3
|
+
game and a substrate for other paging games (policy comparison, Belady's anomaly, next-eviction).
|
|
4
|
+
|
|
5
|
+
This is a faithful implementation of the algorithms, not a hand-waved model — but it is NOT xv6-live
|
|
6
|
+
(xv6 has no eviction). A live page-replacement mechanism in the kernel is a separate, larger build.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ReplayResult:
|
|
15
|
+
policy: str
|
|
16
|
+
frames: int
|
|
17
|
+
faults: int
|
|
18
|
+
evictions: int
|
|
19
|
+
fault_at: list # bool per reference — was it a page fault?
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def simulate(refs, frames: int, policy: str = "lru") -> ReplayResult:
|
|
23
|
+
"""Run `policy` over `refs` with `frames` physical frames. FIFO / LRU / OPT."""
|
|
24
|
+
refs = list(refs)
|
|
25
|
+
policy = policy.lower()
|
|
26
|
+
if frames <= 0:
|
|
27
|
+
return ReplayResult(policy, frames, len(refs), 0, [True] * len(refs))
|
|
28
|
+
if policy == "opt":
|
|
29
|
+
return _opt(refs, frames)
|
|
30
|
+
inset: set = set()
|
|
31
|
+
order: list = [] # FIFO: insertion order; LRU: recency (least-recent at front)
|
|
32
|
+
faults = evictions = 0
|
|
33
|
+
fault_at: list = []
|
|
34
|
+
for pg in refs:
|
|
35
|
+
hit = pg in inset
|
|
36
|
+
fault_at.append(not hit)
|
|
37
|
+
if hit:
|
|
38
|
+
if policy == "lru":
|
|
39
|
+
order.remove(pg); order.append(pg) # touch -> most recent
|
|
40
|
+
continue
|
|
41
|
+
faults += 1
|
|
42
|
+
if len(inset) >= frames:
|
|
43
|
+
victim = order.pop(0) # FIFO oldest / LRU least-recent
|
|
44
|
+
inset.discard(victim); evictions += 1
|
|
45
|
+
inset.add(pg); order.append(pg)
|
|
46
|
+
return ReplayResult(policy, frames, faults, evictions, fault_at)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _opt(refs, frames) -> ReplayResult:
|
|
50
|
+
inset: set = set()
|
|
51
|
+
faults = evictions = 0
|
|
52
|
+
fault_at: list = []
|
|
53
|
+
for i, pg in enumerate(refs):
|
|
54
|
+
hit = pg in inset
|
|
55
|
+
fault_at.append(not hit)
|
|
56
|
+
if hit:
|
|
57
|
+
continue
|
|
58
|
+
faults += 1
|
|
59
|
+
if len(inset) >= frames:
|
|
60
|
+
def next_use(p):
|
|
61
|
+
for j in range(i + 1, len(refs)):
|
|
62
|
+
if refs[j] == p:
|
|
63
|
+
return j
|
|
64
|
+
return 1 << 30 # never used again -> evict first
|
|
65
|
+
victim = max(inset, key=next_use)
|
|
66
|
+
inset.discard(victim); evictions += 1
|
|
67
|
+
inset.add(pg)
|
|
68
|
+
return ReplayResult("opt", frames, faults, evictions, fault_at)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resident_state(refs, frames: int, policy: str = "lru") -> tuple:
|
|
72
|
+
"""Replay `refs` and return (resident_set, order) at the end. `order` is eviction order: FIFO
|
|
73
|
+
insertion order / LRU least-recent-first — so order[0] is the next victim when full."""
|
|
74
|
+
policy = policy.lower()
|
|
75
|
+
inset: set = set()
|
|
76
|
+
order: list = []
|
|
77
|
+
for pg in refs:
|
|
78
|
+
if pg in inset:
|
|
79
|
+
if policy == "lru":
|
|
80
|
+
order.remove(pg); order.append(pg)
|
|
81
|
+
continue
|
|
82
|
+
if len(inset) >= frames and order:
|
|
83
|
+
victim = order.pop(0)
|
|
84
|
+
inset.discard(victim)
|
|
85
|
+
inset.add(pg); order.append(pg)
|
|
86
|
+
return inset, order
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# -- metrics (the observable signature) -------------------------------------------------------- #
|
|
90
|
+
def fault_rate(res: ReplayResult) -> float:
|
|
91
|
+
return res.faults / len(res.fault_at) if res.fault_at else 0.0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def unique_pages(refs) -> int:
|
|
95
|
+
return len(set(refs))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def peak_working_set(refs, window: int = 10) -> int:
|
|
99
|
+
"""Largest number of distinct pages touched within any `window`-reference sliding window."""
|
|
100
|
+
from collections import deque
|
|
101
|
+
w: deque = deque()
|
|
102
|
+
seen: dict = {}
|
|
103
|
+
peak = 0
|
|
104
|
+
for p in refs:
|
|
105
|
+
w.append(p); seen[p] = seen.get(p, 0) + 1
|
|
106
|
+
if len(w) > window:
|
|
107
|
+
old = w.popleft(); seen[old] -= 1
|
|
108
|
+
if seen[old] == 0:
|
|
109
|
+
del seen[old]
|
|
110
|
+
peak = max(peak, len(seen))
|
|
111
|
+
return peak
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def ws_growth(refs) -> float:
|
|
115
|
+
"""How much the distinct-page set grows from the first half to the second (phase change)."""
|
|
116
|
+
if len(refs) < 4:
|
|
117
|
+
return 0.0
|
|
118
|
+
h = len(refs) // 2
|
|
119
|
+
a = len(set(refs[:h])); b = len(set(refs[h:]))
|
|
120
|
+
return max(0.0, (b - a) / max(a, 1))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def locality(refs, window: int = 8) -> float:
|
|
124
|
+
"""Fraction of reuses whose previous touch was within `window` references (1 = tight locality)."""
|
|
125
|
+
last: dict = {}
|
|
126
|
+
hits = total = 0
|
|
127
|
+
for i, p in enumerate(refs):
|
|
128
|
+
if p in last:
|
|
129
|
+
total += 1
|
|
130
|
+
if i - last[p] <= window:
|
|
131
|
+
hits += 1
|
|
132
|
+
last[p] = i
|
|
133
|
+
return hits / total if total else 0.0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def run_features(refs, frames: int, policy: str = "lru") -> dict:
|
|
137
|
+
"""The observable signature of a run — what the thrashing game shows the student."""
|
|
138
|
+
res = simulate(refs, frames, policy)
|
|
139
|
+
return {"fault_rate": fault_rate(res), "frames": frames,
|
|
140
|
+
"working_set": peak_working_set(refs), "unique_pages": unique_pages(refs),
|
|
141
|
+
"ws_growth": ws_growth(refs), "locality": locality(refs)}
|
gini/domain/pricing.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""GINI $ — the toy cloud-economics model behind the dashboard.
|
|
2
|
+
|
|
3
|
+
Every element you place is a "rented" cloud resource billed by the hour in GINI $ (a
|
|
4
|
+
pretend currency — purely pedagogical, so students feel pay-as-you-go without spending
|
|
5
|
+
real money). The dashboard accrues `rate_per_hr × elapsed` while a lab runs.
|
|
6
|
+
|
|
7
|
+
Rates are deliberately round numbers that *teach the relative cost of cloud building
|
|
8
|
+
blocks*: a managed database costs more than a plain VM, which costs more than a switch.
|
|
9
|
+
Override any rate in Settings → Pricing (persisted to ~/.gini).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
# Default rental rates in GINI $ per hour, keyed by element type_key.
|
|
14
|
+
DEFAULT_RATES: dict[str, float] = {
|
|
15
|
+
# --- compute (you pay most for managed/large compute) ------------------- #
|
|
16
|
+
"instance": 10.0, # VM
|
|
17
|
+
"kinstance": 14.0, # Kata Instance — a real microVM, so it costs more than a VM image
|
|
18
|
+
"container": 5.0,
|
|
19
|
+
"host": 2.0, # a plain Machine / end host
|
|
20
|
+
"desktop": 3.0, # a headful Machine (lean host + a light X desktop over noVNC)
|
|
21
|
+
"xv6": 2.0, # xv6 teaching kernel under QEMU-RISC-V (a single-CPU learning VM)
|
|
22
|
+
# --- OS Zoo (emulated historical OSes; each is a QEMU/emulator VM) --------- #
|
|
23
|
+
"freedos": 2.0,
|
|
24
|
+
"kolibri": 2.0,
|
|
25
|
+
"menuet": 2.0,
|
|
26
|
+
"msdos": 2.0, # MS-DOS 6.22 preset (QEMU; disk image downloaded)
|
|
27
|
+
"mac7": 3.0, # Mac System 7 preset (Basilisk II; image/ROM downloaded)
|
|
28
|
+
"win31": 3.0, # Windows 3.11 preset (DOSBox; image downloaded)
|
|
29
|
+
"oszoo_byo": 3.0, # bring-your-own classic OS (Windows/Mac) under emulation
|
|
30
|
+
# --- serverless (cheap, scale-to-zero; pay per use, not per idle hour) --- #
|
|
31
|
+
"function": 1.0, # a handler in the shared FaaS runtime
|
|
32
|
+
"api_gateway": 5.0, # a managed API front door (Traefik)
|
|
33
|
+
# --- containers & kubernetes -------------------------------------------- #
|
|
34
|
+
"pod": 5.0, # a Deployment — billed per replica (scaling costs money)
|
|
35
|
+
"k8s_cluster": 8.0, # a managed control plane (k3s)
|
|
36
|
+
"k8s_node": 8.0, # a worker node (a VM)
|
|
37
|
+
"instance_group": 2.0, # Pod Autoscaler (HPA) — the autoscaling feature itself
|
|
38
|
+
"registry": 3.0, # an image registry
|
|
39
|
+
# --- networking --------------------------------------------------------- #
|
|
40
|
+
"router": 3.0,
|
|
41
|
+
"ovs": 3.0, # OpenFlow switch (a gRouter in OF mode)
|
|
42
|
+
"controller": 4.0, # SDN controller
|
|
43
|
+
"firewall": 2.0,
|
|
44
|
+
"vnf": 3.0, # a network-function container in the path (NFV)
|
|
45
|
+
"switch": 1.0,
|
|
46
|
+
"wap": 1.5, # access point
|
|
47
|
+
"hub": 0.5,
|
|
48
|
+
"cloud": 1.0, # the Internet element = a NAT gateway (small fee)
|
|
49
|
+
# --- managed services (the expensive, "someone runs it for you" tier) --- #
|
|
50
|
+
"database": 12.0,
|
|
51
|
+
"nosql": 10.0,
|
|
52
|
+
"object_store": 8.0,
|
|
53
|
+
"stream": 9.0,
|
|
54
|
+
"queue": 6.0,
|
|
55
|
+
"messaging": 6.0,
|
|
56
|
+
"cache": 4.0,
|
|
57
|
+
"load_balancer": 5.0,
|
|
58
|
+
"proxy": 3.0,
|
|
59
|
+
"web_app": 4.0,
|
|
60
|
+
# --- observability / workload ------------------------------------------ #
|
|
61
|
+
"metrics": 5.0, # Prometheus
|
|
62
|
+
"dashboard": 5.0, # Grafana
|
|
63
|
+
"tracing": 5.0, # Jaeger
|
|
64
|
+
"load_generator": 2.0,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Instance "size" tiers (like cloud instance types). level -> (label, vCPUs, mem_MB,
|
|
68
|
+
# cost_multiplier). Cost roughly doubles each step up, mirroring real cloud pricing.
|
|
69
|
+
SIZE_TIERS: dict[int, tuple[str, float, int, int]] = {
|
|
70
|
+
1: ("S", 0.5, 256, 1),
|
|
71
|
+
2: ("M", 1.0, 512, 2),
|
|
72
|
+
3: ("L", 2.0, 1024, 4),
|
|
73
|
+
4: ("XL", 4.0, 2048, 8),
|
|
74
|
+
}
|
|
75
|
+
SIZE_MIN, SIZE_MAX = 1, 4
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def size_level(level) -> int:
|
|
79
|
+
try:
|
|
80
|
+
return max(SIZE_MIN, min(SIZE_MAX, int(level or 1)))
|
|
81
|
+
except (TypeError, ValueError):
|
|
82
|
+
return 1
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def size_tier(level) -> tuple[str, float, int, int]:
|
|
86
|
+
return SIZE_TIERS[size_level(level)]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def size_label(level) -> str:
|
|
90
|
+
return size_tier(level)[0]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def size_cost_mult(level) -> int:
|
|
94
|
+
return size_tier(level)[3]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def resizable(type_key: str) -> bool:
|
|
98
|
+
"""Which elements expose a size knob — things that run a real workload container
|
|
99
|
+
and meaningfully have a capacity (compute + managed services). Not switches/hubs."""
|
|
100
|
+
from ..services.cloud_catalog import is_service # lazy: keep domain below services
|
|
101
|
+
return is_service(type_key) or type_key in ("instance", "kinstance", "container", "host", "xv6")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Dashboard breakdown groups. Order is the display order.
|
|
105
|
+
CATEGORIES: dict[str, tuple[str, ...]] = {
|
|
106
|
+
"Compute": ("instance", "kinstance", "container", "host", "desktop", "xv6"),
|
|
107
|
+
"Serverless": ("function", "api_gateway"),
|
|
108
|
+
"Kubernetes": ("k8s_cluster", "pod", "k8s_node", "instance_group", "registry"),
|
|
109
|
+
"Networking": ("router", "ovs", "controller", "firewall", "vnf", "switch",
|
|
110
|
+
"wap", "hub", "cloud"),
|
|
111
|
+
"Services": ("database", "nosql", "object_store", "stream", "queue",
|
|
112
|
+
"messaging", "cache", "load_balancer", "proxy", "web_app"),
|
|
113
|
+
"Observability": ("metrics", "dashboard", "tracing", "load_generator"),
|
|
114
|
+
"OS Zoo": ("freedos", "kolibri", "menuet", "msdos", "mac7", "win31", "oszoo_byo"),
|
|
115
|
+
}
|
|
116
|
+
CATEGORY_ORDER = tuple(CATEGORIES.keys())
|
|
117
|
+
_CAT_OF = {tk: cat for cat, tks in CATEGORIES.items() for tk in tks}
|
|
118
|
+
|
|
119
|
+
# the type_keys that are actually billable (everything in a category).
|
|
120
|
+
BILLABLE = frozenset(_CAT_OF)
|
|
121
|
+
|
|
122
|
+
# Intentionally-free elements, listed so the coverage test can tell "deliberately not
|
|
123
|
+
# billed" from "someone added an element and forgot to price it" (the latter would make
|
|
124
|
+
# the meter under-count — the bug this guards against). Two kinds:
|
|
125
|
+
# * grouping boundaries — visual regions, not rented resources;
|
|
126
|
+
# * placeholders — elements with no real runtime yet (priced once they become real).
|
|
127
|
+
FREE = frozenset({
|
|
128
|
+
"vpc", "cloud_subnet", "region", # grouping boundaries
|
|
129
|
+
"security_group", "gateway", "block_volume", # not-yet-real placeholders
|
|
130
|
+
"terminal", "storage_volume", # xv6 peripherals (software devices, no cost)
|
|
131
|
+
# A GINI32 board is REAL hardware the student already owns, not a rented
|
|
132
|
+
# resource. The meter models a cloud bill, so billing your own $5 chip would be
|
|
133
|
+
# the wrong lesson — the relay that carries it is part of the lab, not a rental.
|
|
134
|
+
"gini32",
|
|
135
|
+
# Sources / Sinks (riders) — instruments that run inside a donor, no container of their own
|
|
136
|
+
"ping_probe", "http_probe", "packet_view", "dns_probe", "traceroute_probe",
|
|
137
|
+
"iperf_client", "iperf_server", "iface_stats", "xv6_shell", "xv6_workload",
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def category_of(type_key: str) -> str | None:
|
|
142
|
+
return _CAT_OF.get(type_key)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def rate_of(type_key: str, overrides: dict | None = None) -> float:
|
|
146
|
+
"""GINI $/hr for a type, honoring user overrides from Settings → Pricing."""
|
|
147
|
+
if overrides and type_key in overrides:
|
|
148
|
+
try:
|
|
149
|
+
return max(0.0, float(overrides[type_key]))
|
|
150
|
+
except (TypeError, ValueError):
|
|
151
|
+
pass
|
|
152
|
+
return float(DEFAULT_RATES.get(type_key, 0.0))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _int(v, default: int = 1) -> int:
|
|
156
|
+
try:
|
|
157
|
+
return int(float(v))
|
|
158
|
+
except (TypeError, ValueError):
|
|
159
|
+
return default
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def units(type_key: str, properties: dict | None) -> int:
|
|
163
|
+
"""How many billable units one element represents. A Pod is a Deployment of N
|
|
164
|
+
replicas, so it bills per replica — scaling a workload up costs more, which is the
|
|
165
|
+
whole point of Kubernetes/HPA. Everything else is a single unit."""
|
|
166
|
+
if type_key == "pod":
|
|
167
|
+
return max(1, _int((properties or {}).get("Replicas"), 1))
|
|
168
|
+
return 1
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def bill(topology, overrides: dict | None = None) -> dict:
|
|
172
|
+
"""Cost estimate for the whole canvas.
|
|
173
|
+
|
|
174
|
+
Returns::
|
|
175
|
+
|
|
176
|
+
{"rate_per_hr": float, # GINI $/hr for every billable element
|
|
177
|
+
"count": int, # number of billable elements
|
|
178
|
+
"by_category": {cat: {"rate": float, "count": int}}} # display order
|
|
179
|
+
"""
|
|
180
|
+
by_cat: dict[str, dict] = {c: {"rate": 0.0, "count": 0} for c in CATEGORY_ORDER}
|
|
181
|
+
total = 0.0
|
|
182
|
+
n = 0
|
|
183
|
+
for d in getattr(topology, "devices", {}).values():
|
|
184
|
+
tk = getattr(d, "type_key", None)
|
|
185
|
+
if tk not in BILLABLE:
|
|
186
|
+
continue
|
|
187
|
+
cat = _CAT_OF[tk]
|
|
188
|
+
# bigger instance size = proportionally more GINI $/hr (x1/x2/x4/x8)
|
|
189
|
+
mult = size_cost_mult(getattr(d, "size", 1)) if resizable(tk) else 1
|
|
190
|
+
# a Pod bills per replica (scaling a workload costs more)
|
|
191
|
+
qty = units(tk, getattr(d, "properties", None))
|
|
192
|
+
r = rate_of(tk, overrides) * mult * qty
|
|
193
|
+
by_cat[cat]["rate"] += r
|
|
194
|
+
by_cat[cat]["count"] += 1
|
|
195
|
+
total += r
|
|
196
|
+
n += 1
|
|
197
|
+
# drop empty categories so the strip stays compact
|
|
198
|
+
by_cat = {c: v for c, v in by_cat.items() if v["count"]}
|
|
199
|
+
return {"rate_per_hr": round(total, 2), "count": n, "by_category": by_cat}
|
gini/domain/probes.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Behavioral probes — the language of GINI-as-oracle win conditions.
|
|
2
|
+
|
|
3
|
+
A behavioral objective asserts a fact about the RUNNING system, which only GINI's runtime can
|
|
4
|
+
witness. This module parses the probe strings and evaluates them against a `Runner` — the thin
|
|
5
|
+
seam over the running topology (Docker exec of ping/curl, flow-table reads). GINI, never the
|
|
6
|
+
model, produces the verdict.
|
|
7
|
+
|
|
8
|
+
Probe grammar (Phase 2):
|
|
9
|
+
reach(A -> B) == ok|fail # L3 reachability (ICMP); with :port → TCP/L4
|
|
10
|
+
reach(A -> B:port) == ok|fail
|
|
11
|
+
reach(A -> B, all) == ok # EVERY A-B pair, not just some (see TypeRunner.reach_all)
|
|
12
|
+
ping(A -> B) == ok|fail # explicit ICMP
|
|
13
|
+
http(A -> B:port) == ok|fail # an HTTP 2xx from A to B:port
|
|
14
|
+
balances(LB, >= n) # LB fans out to at least n live backends
|
|
15
|
+
flow_installed(OVS, <match>) # an OpenFlow rule matching <match> is installed
|
|
16
|
+
|
|
17
|
+
Pure parsing + evaluation; the real Docker-backed runner lives in `services/probe_runner.py`,
|
|
18
|
+
and a `FakeRunner` (facts dict) makes everything unit-testable offline.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Protocol
|
|
25
|
+
|
|
26
|
+
# probe kinds
|
|
27
|
+
REACH, PING, HTTP, BALANCES, FLOW = "reach", "ping", "http", "balances", "flow_installed"
|
|
28
|
+
MEASURE = "measure"
|
|
29
|
+
|
|
30
|
+
_REACH_RE = re.compile( # `\w+` or a slot-scoped `\w+@\w+` (e.g. host@A)
|
|
31
|
+
r"^\s*(reach|ping|http)\(\s*([\w@]+)\s*->\s*([\w@]+)(?::(\d+))?\s*(?:,\s*(all|any)\s*)?\)"
|
|
32
|
+
r"\s*==\s*(ok|fail)\s*$")
|
|
33
|
+
_BAL_RE = re.compile(r"^\s*balances\(\s*(\w+)\s*,\s*>=\s*(\d+)\s*\)\s*$")
|
|
34
|
+
_FLOW_RE = re.compile(r"^\s*flow_installed\(\s*(\w+)\s*,\s*(.+?)\s*\)\s*$")
|
|
35
|
+
# measure(<rider_type>, <metric>) <op> <value> — an output assertion on a Sink/Source measurement
|
|
36
|
+
_MEASURE_RE = re.compile(
|
|
37
|
+
r"^\s*measure\(\s*(\w+)\s*,\s*(\w+)\s*\)\s*(>=|<=|==|!=|>|<)\s*(-?\d+(?:\.\d+)?)\s*$")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProbeError(ValueError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Probe:
|
|
46
|
+
kind: str
|
|
47
|
+
src: str = ""
|
|
48
|
+
dst: str = ""
|
|
49
|
+
port: int | None = None
|
|
50
|
+
expect_ok: bool = True # for reach/ping/http: True = expect success, False = expect failure
|
|
51
|
+
n: int = 0 # for balances
|
|
52
|
+
match: str = "" # for flow_installed
|
|
53
|
+
quant: str = "any" # "any" = SOME pair satisfies it; "all" = EVERY pair must
|
|
54
|
+
metric: str = "" # for measure: the rider metric (loss_pct, packets, mbps, …)
|
|
55
|
+
op: str = ">=" # for measure: comparison operator
|
|
56
|
+
value: float = 0.0 # for measure: the threshold
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Runner(Protocol):
|
|
60
|
+
"""The running-topology seam. `available()` is False when there's no live runtime, so
|
|
61
|
+
behavioral objectives stay `pending` rather than being wrongly failed."""
|
|
62
|
+
def available(self) -> bool: ...
|
|
63
|
+
def reach(self, src: str, dst: str, port: int | None = None) -> bool: ...
|
|
64
|
+
def http(self, src: str, dst: str, port: int) -> bool: ...
|
|
65
|
+
def backends(self, lb: str) -> int: ...
|
|
66
|
+
def flow(self, ovs: str, match: str) -> bool: ...
|
|
67
|
+
def measure(self, rider_type: str, metric: str) -> float | None: ...
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse(probe: str) -> Probe:
|
|
71
|
+
m = _REACH_RE.match(probe or "")
|
|
72
|
+
if m:
|
|
73
|
+
kind, src, dst, port, quant, expect = m.groups()
|
|
74
|
+
return Probe(kind=kind, src=src, dst=dst,
|
|
75
|
+
port=int(port) if port else None, expect_ok=(expect == "ok"),
|
|
76
|
+
quant=quant or "any")
|
|
77
|
+
m = _BAL_RE.match(probe or "")
|
|
78
|
+
if m:
|
|
79
|
+
return Probe(kind=BALANCES, src=m.group(1), n=int(m.group(2)))
|
|
80
|
+
m = _FLOW_RE.match(probe or "")
|
|
81
|
+
if m:
|
|
82
|
+
return Probe(kind=FLOW, src=m.group(1), match=m.group(2))
|
|
83
|
+
m = _MEASURE_RE.match(probe or "")
|
|
84
|
+
if m:
|
|
85
|
+
return Probe(kind=MEASURE, src=m.group(1), metric=m.group(2),
|
|
86
|
+
op=m.group(3), value=float(m.group(4)))
|
|
87
|
+
raise ProbeError(f"cannot parse probe: {probe!r}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_OPS = {">=": lambda a, b: a >= b, "<=": lambda a, b: a <= b, ">": lambda a, b: a > b,
|
|
91
|
+
"<": lambda a, b: a < b, "==": lambda a, b: a == b, "!=": lambda a, b: a != b}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def probe_ok(probe: str) -> bool:
|
|
95
|
+
try:
|
|
96
|
+
parse(probe)
|
|
97
|
+
return True
|
|
98
|
+
except ProbeError:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def evaluate(probe: str, runner: Runner) -> bool:
|
|
103
|
+
"""Evaluate a probe string against a live runner → the boolean fact.
|
|
104
|
+
|
|
105
|
+
The `all` quantifier only means something to a TYPE-resolving runner (one token can name many
|
|
106
|
+
devices). A plain name-based runner has exactly one pair, so `all` and `any` coincide and it
|
|
107
|
+
can simply not implement `*_all` — hence the getattr fallback rather than a protocol break."""
|
|
108
|
+
p = parse(probe)
|
|
109
|
+
if p.kind in (REACH, PING):
|
|
110
|
+
fn = getattr(runner, "reach_all", None) if p.quant == "all" else None
|
|
111
|
+
got = fn(p.src, p.dst, p.port) if fn else runner.reach(p.src, p.dst, p.port)
|
|
112
|
+
return got == p.expect_ok
|
|
113
|
+
if p.kind == HTTP:
|
|
114
|
+
fn = getattr(runner, "http_all", None) if p.quant == "all" else None
|
|
115
|
+
port = p.port or 80
|
|
116
|
+
got = fn(p.src, p.dst, port) if fn else runner.http(p.src, p.dst, port)
|
|
117
|
+
return got == p.expect_ok
|
|
118
|
+
if p.kind == BALANCES:
|
|
119
|
+
return runner.backends(p.src) >= p.n
|
|
120
|
+
if p.kind == FLOW:
|
|
121
|
+
return runner.flow(p.src, p.match)
|
|
122
|
+
if p.kind == MEASURE:
|
|
123
|
+
got = runner.measure(p.src, p.metric)
|
|
124
|
+
if got is None:
|
|
125
|
+
return False # no reading yet — treat as not-satisfied, not a crash
|
|
126
|
+
return _OPS[p.op](got, p.value)
|
|
127
|
+
raise ProbeError(f"unknown probe kind {p.kind!r}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# -- type-aware resolution (name-agnostic behavioral probes) ---------------- #
|
|
131
|
+
class TypeRunner:
|
|
132
|
+
"""Wraps a name-based `Runner` so a probe's tokens are element TYPE keys, resolved existentially
|
|
133
|
+
against the live topology — the behavioral analogue of our type-based structural predicates.
|
|
134
|
+
`reach(web_app -> database)` becomes "SOME web_app device reaches SOME database device", so a
|
|
135
|
+
behavioral objective matches whatever the student actually named things (exactly like `path`).
|
|
136
|
+
|
|
137
|
+
`get_topology()` returns the live topology (anything with `.devices` whose values have
|
|
138
|
+
`.name`/`.type_key`). Delegates availability to the wrapped runner."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, base: Runner, get_topology) -> None:
|
|
141
|
+
self.base = base
|
|
142
|
+
self._get_topology = get_topology
|
|
143
|
+
|
|
144
|
+
def available(self) -> bool:
|
|
145
|
+
return bool(self.base) and self.base.available()
|
|
146
|
+
|
|
147
|
+
def _names(self, spec: str) -> list[str]:
|
|
148
|
+
"""Names of devices matching `type` or a slot-scoped `type@slot` (cross-slot reach). Slot
|
|
149
|
+
scope is hierarchical: `host@pods0` includes hosts nested in `pods0_lans1` (see slot_match)."""
|
|
150
|
+
from .objectives import slot_match
|
|
151
|
+
type_key, _, slot = str(spec).partition("@")
|
|
152
|
+
t = self._get_topology()
|
|
153
|
+
return [d.name for d in getattr(t, "devices", {}).values()
|
|
154
|
+
if getattr(d, "type_key", None) == type_key
|
|
155
|
+
and slot_match(getattr(d, "slot", ""), slot)]
|
|
156
|
+
|
|
157
|
+
def _pairs(self, src: str, dst: str):
|
|
158
|
+
"""Every (src, dst) name pair — EXCLUDING a device paired with itself. When both tokens are
|
|
159
|
+
the same type (`reach(host -> host)`, the LAN-repair case) the self-pair would make the
|
|
160
|
+
probe vacuously true: a host can always ping its own loopback. Proving a host talks to
|
|
161
|
+
*itself* proves nothing about the network."""
|
|
162
|
+
for s in self._names(src):
|
|
163
|
+
for d in self._names(dst):
|
|
164
|
+
if s != d:
|
|
165
|
+
yield s, d
|
|
166
|
+
|
|
167
|
+
def reach(self, src: str, dst: str, port: int | None = None) -> bool:
|
|
168
|
+
return any(self.base.reach(s, d, port) for s, d in self._pairs(src, dst))
|
|
169
|
+
|
|
170
|
+
def http(self, src: str, dst: str, port: int) -> bool:
|
|
171
|
+
return any(self.base.http(s, d, port) for s, d in self._pairs(src, dst))
|
|
172
|
+
|
|
173
|
+
# -- universal ("all") resolution --------------------------------------- #
|
|
174
|
+
# `reach(host -> host, all)` = EVERY host reaches every other. Needed for repair missions: with
|
|
175
|
+
# the existential reading, one healthy pair would mask a broken third host. An empty pair set is
|
|
176
|
+
# False, not vacuously True — "no hosts" must never look like "all hosts talk".
|
|
177
|
+
def reach_all(self, src: str, dst: str, port: int | None = None) -> bool:
|
|
178
|
+
pairs = list(self._pairs(src, dst))
|
|
179
|
+
return bool(pairs) and all(self.base.reach(s, d, port) for s, d in pairs)
|
|
180
|
+
|
|
181
|
+
def http_all(self, src: str, dst: str, port: int) -> bool:
|
|
182
|
+
pairs = list(self._pairs(src, dst))
|
|
183
|
+
return bool(pairs) and all(self.base.http(s, d, port) for s, d in pairs)
|
|
184
|
+
|
|
185
|
+
def backends(self, lb: str) -> int:
|
|
186
|
+
return max((self.base.backends(n) for n in self._names(lb)), default=0)
|
|
187
|
+
|
|
188
|
+
def flow(self, ovs: str, match: str) -> bool:
|
|
189
|
+
return any(self.base.flow(n, match) for n in self._names(ovs))
|
|
190
|
+
|
|
191
|
+
def measure(self, rider_type: str, metric: str) -> float | None:
|
|
192
|
+
# measure is inherently type-based (you assert on "the packet_view" of the fragment) —
|
|
193
|
+
# delegate straight to the base, which finds a rider of that type and runs it.
|
|
194
|
+
fn = getattr(self.base, "measure", None)
|
|
195
|
+
return fn(rider_type, metric) if fn else None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# -- a fake runner for tests + offline authoring ---------------------------- #
|
|
199
|
+
class FakeRunner:
|
|
200
|
+
"""A scriptable runner backed by a facts dict — for unit tests and lesson playtests without
|
|
201
|
+
Docker. Facts keys: ('reach', src, dst, port) -> bool, ('http', src, dst, port) -> bool,
|
|
202
|
+
('backends', lb) -> int, ('flow', ovs, match) -> bool."""
|
|
203
|
+
|
|
204
|
+
def __init__(self, facts: dict | None = None, available: bool = True,
|
|
205
|
+
default: bool = False) -> None:
|
|
206
|
+
self.facts = facts or {}
|
|
207
|
+
self._available = available
|
|
208
|
+
self._default = default # verdict for unscripted reach/http facts
|
|
209
|
+
|
|
210
|
+
def available(self) -> bool:
|
|
211
|
+
return self._available
|
|
212
|
+
|
|
213
|
+
def reach(self, src: str, dst: str, port: int | None = None) -> bool:
|
|
214
|
+
return bool(self.facts.get(("reach", src, dst, port), self._default))
|
|
215
|
+
|
|
216
|
+
def http(self, src: str, dst: str, port: int) -> bool:
|
|
217
|
+
return bool(self.facts.get(("http", src, dst, port), self._default))
|
|
218
|
+
|
|
219
|
+
def backends(self, lb: str) -> int:
|
|
220
|
+
return int(self.facts.get(("backends", lb), 0))
|
|
221
|
+
|
|
222
|
+
def measure(self, rider_type: str, metric: str) -> float | None:
|
|
223
|
+
return self.facts.get(("measure", rider_type, metric))
|
|
224
|
+
|
|
225
|
+
def flow(self, ovs: str, match: str) -> bool:
|
|
226
|
+
return bool(self.facts.get(("flow", ovs, match), False))
|