bytesim 0.1.0__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.
- bytesim/__init__.py +14 -0
- bytesim/__main__.py +3 -0
- bytesim/cli.py +45 -0
- bytesim/constants.py +48 -0
- bytesim/demos.py +74 -0
- bytesim/model/__init__.py +5 -0
- bytesim/model/addressing.py +160 -0
- bytesim/model/base.py +57 -0
- bytesim/model/cloning.py +249 -0
- bytesim/model/equivalence.py +86 -0
- bytesim/model/groups.py +133 -0
- bytesim/model/ha.py +93 -0
- bytesim/model/layer2.py +106 -0
- bytesim/model/layout.py +203 -0
- bytesim/model/naming.py +114 -0
- bytesim/model/network.py +138 -0
- bytesim/model/routing.py +117 -0
- bytesim/model/stp.py +315 -0
- bytesim/model/types.py +107 -0
- bytesim/py.typed +0 -0
- bytesim/scripting.py +108 -0
- bytesim/synthesis.py +644 -0
- bytesim/topology.py +517 -0
- bytesim/tui/__init__.py +4 -0
- bytesim/tui/app.py +161 -0
- bytesim/tui/cables.py +58 -0
- bytesim/tui/canvas.py +489 -0
- bytesim/tui/command_input.py +29 -0
- bytesim/tui/commands/__init__.py +1 -0
- bytesim/tui/commands/addressing.py +235 -0
- bytesim/tui/commands/base.py +43 -0
- bytesim/tui/commands/groups.py +215 -0
- bytesim/tui/commands/history.py +134 -0
- bytesim/tui/commands/interpreter.py +85 -0
- bytesim/tui/commands/naming.py +68 -0
- bytesim/tui/commands/registry.py +28 -0
- bytesim/tui/commands/scripting.py +54 -0
- bytesim/tui/commands/switching.py +192 -0
- bytesim/tui/commands/topology.py +255 -0
- bytesim/tui/commands/traffic.py +312 -0
- bytesim/tui/help.py +50 -0
- bytesim/tui/inspector.py +115 -0
- bytesim/tui/theme.py +12 -0
- bytesim-0.1.0.dist-info/METADATA +178 -0
- bytesim-0.1.0.dist-info/RECORD +48 -0
- bytesim-0.1.0.dist-info/WHEEL +4 -0
- bytesim-0.1.0.dist-info/entry_points.txt +2 -0
- bytesim-0.1.0.dist-info/licenses/LICENSE +21 -0
bytesim/synthesis.py
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
"""Synthesize a ByteSim command script that rebuilds a network.
|
|
2
|
+
|
|
3
|
+
The script is written in ByteSim's own console language, so it can be replayed
|
|
4
|
+
with ``run <file>`` (after ``clear``). Every script is checked before it is
|
|
5
|
+
returned: it is replayed into a fresh network by the real command handlers
|
|
6
|
+
(``bytesim.scripting``) and compared with the original (``model.equivalence``).
|
|
7
|
+
|
|
8
|
+
How it works:
|
|
9
|
+
|
|
10
|
+
1. **Explicit plan.** Devices are created in id order (``add s 5 3`` makes the
|
|
11
|
+
next three switches), with placeholders created and deleted to recreate gaps
|
|
12
|
+
in the numbering. Then groups (innermost first), HA clusters, interface
|
|
13
|
+
settings, every cable on its exact ports, STP and DHCP-server settings, and
|
|
14
|
+
finally failures. This always works, and it is the fallback.
|
|
15
|
+
2. **Reuse.** ByteSim's ``clone`` is deterministic: the source's devices, sorted
|
|
16
|
+
by kind and number, get the next free ids in order. So a group B can be
|
|
17
|
+
cloned from an earlier group A exactly when that one mapping preserves
|
|
18
|
+
kinds, port counts, internal cables (with ports and roles), the nested group
|
|
19
|
+
tree, clusters and settings, and B's ids are consecutive. ``ports copy`` and
|
|
20
|
+
``copy A(…) B(…)`` reproduce a group's cabling to other groups when it follows
|
|
21
|
+
another group's pattern, with groups matched by position.
|
|
22
|
+
3. **Choice.** Candidates are tried best saving first; each is kept only if the
|
|
23
|
+
script with it still replays into an equivalent network and is shorter.
|
|
24
|
+
Search is bounded (``Limits``), so ``shortest`` means the shortest script
|
|
25
|
+
found within those limits, not a proven optimum.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import time
|
|
30
|
+
from collections import Counter
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from itertools import combinations, permutations
|
|
33
|
+
|
|
34
|
+
from .constants import KINDS, RANK
|
|
35
|
+
from .model import NetError, Network
|
|
36
|
+
from .model.equivalence import DEFAULT_MODE, differences, iface_config
|
|
37
|
+
|
|
38
|
+
MODES = ("explicit", "readable", "shortest")
|
|
39
|
+
READABLE_MIN_SAVING = 3 # readable mode only reuses structure when it saves this many commands
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Limits:
|
|
44
|
+
max_candidates: int = 60 # candidate substitutions tried (each costs one replay)
|
|
45
|
+
max_seconds: float = 8.0 # total optimisation time
|
|
46
|
+
max_group_size: int = 300 # larger groups aren't considered for reuse
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Substitution:
|
|
51
|
+
kind: str # clone | copy | ports
|
|
52
|
+
src: str
|
|
53
|
+
dst: str
|
|
54
|
+
pairs: list[tuple[str, str]] = field(default_factory=list)
|
|
55
|
+
claims: Counter = field(default_factory=Counter) # (dst device, endpoint device, role) cables it makes
|
|
56
|
+
saves: int = 0
|
|
57
|
+
|
|
58
|
+
def command(self) -> str:
|
|
59
|
+
if self.kind == "clone":
|
|
60
|
+
return f"clone {self.src} {self.dst}"
|
|
61
|
+
old = ",".join(o for o, _ in self.pairs)
|
|
62
|
+
new = ",".join(n for _, n in self.pairs)
|
|
63
|
+
prefix = "ports copy" if self.kind == "ports" else "copy"
|
|
64
|
+
return f"{prefix} {self.src}({old}) {self.dst}({new})"
|
|
65
|
+
|
|
66
|
+
def describe(self) -> str:
|
|
67
|
+
if self.kind == "clone":
|
|
68
|
+
return f"{self.dst} has the same structure as {self.src}: clone it"
|
|
69
|
+
ends = ", ".join(f"{o}→{n}" for o, n in self.pairs)
|
|
70
|
+
if self.kind == "copy":
|
|
71
|
+
return f"{self.dst} is {self.src} again, cabled the same way with {ends}: copy it"
|
|
72
|
+
return f"{self.dst} is cabled like {self.src}, with {ends}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class Synthesis:
|
|
77
|
+
script: str
|
|
78
|
+
mode: str
|
|
79
|
+
commands: int
|
|
80
|
+
explicit_commands: int
|
|
81
|
+
used: list[Substitution]
|
|
82
|
+
limitations: list[str]
|
|
83
|
+
tried: int = 0
|
|
84
|
+
rejected: int = 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _Infeasible(Exception):
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _order(did: str) -> tuple[int, int]:
|
|
92
|
+
return RANK[did[0]], int(did[1:])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _commands(lines: list[str]) -> int:
|
|
96
|
+
return sum(1 for ln in lines if ln and not ln.startswith("#"))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _cost(lines: list[str]) -> tuple[int, int]:
|
|
100
|
+
body = [ln for ln in lines if ln and not ln.startswith("#")]
|
|
101
|
+
return len(body), sum(len(ln) for ln in body)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------- structure helpers
|
|
105
|
+
def _subtree(net: Network, g: str) -> list[str]:
|
|
106
|
+
out = [g]
|
|
107
|
+
for m in net.groups[g]:
|
|
108
|
+
if m in net.groups:
|
|
109
|
+
out += _subtree(net, m)
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _postorder(net: Network, g: str) -> list[str]:
|
|
114
|
+
out: list[str] = []
|
|
115
|
+
for m in net.groups[g]:
|
|
116
|
+
if m in net.groups:
|
|
117
|
+
out += _postorder(net, m)
|
|
118
|
+
return out + [g]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _clone_map(net: Network, a: str, b: str) -> dict[str, str] | None:
|
|
122
|
+
"""The device mapping `clone a` would produce if b's ids were the ones allocated."""
|
|
123
|
+
da, db = net.devices_in(a), net.devices_in(b)
|
|
124
|
+
if len(da) != len(db) or set(da) & set(db):
|
|
125
|
+
return None
|
|
126
|
+
phi = {}
|
|
127
|
+
for k in KINDS:
|
|
128
|
+
xa = sorted((d for d in da if d[0] == k), key=_order)
|
|
129
|
+
xb = sorted((d for d in db if d[0] == k), key=_order)
|
|
130
|
+
if len(xa) != len(xb):
|
|
131
|
+
return None
|
|
132
|
+
nums = [int(d[1:]) for d in xb]
|
|
133
|
+
if nums and (nums != list(range(nums[0], nums[0] + len(nums))) or int(xa[-1][1:]) >= nums[0]):
|
|
134
|
+
return None # b's ids must be consecutive and come after a's
|
|
135
|
+
phi.update(zip(xa, xb))
|
|
136
|
+
return phi
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _clone_equivalent(net: Network, a: str, b: str, phi: dict[str, str]) -> bool:
|
|
140
|
+
for x, y in phi.items():
|
|
141
|
+
dx, dy = net.devices[x], net.devices[y]
|
|
142
|
+
if dx.nports != dy.nports:
|
|
143
|
+
return False
|
|
144
|
+
if (x in net.stp_off) != (y in net.stp_off) or net.stp_priority.get(x) != net.stp_priority.get(y):
|
|
145
|
+
return False
|
|
146
|
+
if (x in net.dhcp_off) != (y in net.dhcp_off):
|
|
147
|
+
return False
|
|
148
|
+
if dx.kind != "s":
|
|
149
|
+
for p in range(1, dx.nports + 1):
|
|
150
|
+
mode, _ = iface_config(net, x, p)
|
|
151
|
+
if mode == "static": # clone turns static into dhcp/auto, without addresses
|
|
152
|
+
mode = "dhcp" if dx.kind == "c" else "auto"
|
|
153
|
+
if iface_config(net, y, p) != (mode, None):
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
def same_tree(ga: str, gb: str) -> bool:
|
|
157
|
+
ma, mb = net.groups[ga], net.groups[gb]
|
|
158
|
+
if len(ma) != len(mb) or net.clusters.get(ga) != net.clusters.get(gb):
|
|
159
|
+
return False
|
|
160
|
+
for x, y in zip(ma, mb):
|
|
161
|
+
if x in net.groups:
|
|
162
|
+
if y not in net.groups or not same_tree(x, y):
|
|
163
|
+
return False
|
|
164
|
+
elif phi.get(x) != y:
|
|
165
|
+
return False
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
if not same_tree(a, b):
|
|
169
|
+
return False
|
|
170
|
+
inside_a, inside_b = set(phi), set(phi.values())
|
|
171
|
+
from .model.equivalence import link_key
|
|
172
|
+
want = Counter(link_key(phi[l.a], l.pa, phi[l.b], l.pb, l.role)
|
|
173
|
+
for l in net.links if l.a in inside_a and l.b in inside_a)
|
|
174
|
+
have = Counter(link_key(l.a, l.pa, l.b, l.pb, l.role)
|
|
175
|
+
for l in net.links if l.a in inside_b and l.b in inside_b)
|
|
176
|
+
return want == have
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _boundary(net: Network, devs: set[str]) -> list[tuple[str, str, str]]:
|
|
180
|
+
"""(inside device, outside device, role) for every cable leaving a device set."""
|
|
181
|
+
out = []
|
|
182
|
+
for l in net.links:
|
|
183
|
+
if (l.a in devs) != (l.b in devs):
|
|
184
|
+
near, far = (l.a, l.b) if l.a in devs else (l.b, l.a)
|
|
185
|
+
out.append((near, far, l.role))
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _endpoint_groups(net: Network, group: str, boundary: list[tuple[str, str, str]]) -> list[str]:
|
|
190
|
+
"""Innermost groups of a boundary's far ends, in first-seen order (not ones containing the group)."""
|
|
191
|
+
around = set(net.chain(group)) | {group}
|
|
192
|
+
out: list[str] = []
|
|
193
|
+
for _, far, _ in boundary:
|
|
194
|
+
g = net.group_of(far)
|
|
195
|
+
if g and g not in around and g not in out:
|
|
196
|
+
out.append(g)
|
|
197
|
+
return out
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _pattern(net: Network, a: str, near_map: dict[str, str],
|
|
201
|
+
pairs: list[tuple[str, str]]) -> Counter | None:
|
|
202
|
+
"""The cables (dst device, new endpoint device, role) a copy of a's boundary would make."""
|
|
203
|
+
ends: dict[str, str] = {}
|
|
204
|
+
for e, f in pairs:
|
|
205
|
+
de, df = net.devices_in(e), net.devices_in(f)
|
|
206
|
+
if len(de) != len(df):
|
|
207
|
+
return None
|
|
208
|
+
for x, y in zip(de, df):
|
|
209
|
+
if net.devices[x].kind != net.devices[y].kind or x in ends:
|
|
210
|
+
return None
|
|
211
|
+
ends[x] = y
|
|
212
|
+
out: Counter = Counter()
|
|
213
|
+
for near, far, role in _boundary(net, set(near_map)):
|
|
214
|
+
if far in ends:
|
|
215
|
+
out[(near_map[near], ends[far], role)] += 1
|
|
216
|
+
return out
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _best_rewire(net: Network, a: str, b: str, near_map: dict[str, str]) -> tuple[list, Counter] | None:
|
|
220
|
+
"""Endpoint pairs under which b's outside cabling repeats a's (largest match wins)."""
|
|
221
|
+
ba, bb = _boundary(net, set(near_map)), _boundary(net, set(near_map.values()))
|
|
222
|
+
ea, fb = _endpoint_groups(net, a, ba), _endpoint_groups(net, b, bb)
|
|
223
|
+
if not ea or not fb or len(ea) > 4 or len(fb) > 4:
|
|
224
|
+
return None
|
|
225
|
+
have = Counter((near, far, role) for near, far, role in bb)
|
|
226
|
+
inside_b = set(near_map.values())
|
|
227
|
+
best = None
|
|
228
|
+
for r in range(min(len(ea), len(fb)), 0, -1):
|
|
229
|
+
for es in combinations(ea, r):
|
|
230
|
+
for fs in permutations(fb, r):
|
|
231
|
+
pairs = list(zip(es, fs))
|
|
232
|
+
if any(set(net.devices_in(f)) & inside_b for f in fs):
|
|
233
|
+
continue
|
|
234
|
+
pat = _pattern(net, a, near_map, pairs)
|
|
235
|
+
if not pat or pat - have:
|
|
236
|
+
continue
|
|
237
|
+
if best is None or sum(pat.values()) > sum(best[1].values()):
|
|
238
|
+
best = (pairs, pat)
|
|
239
|
+
if best:
|
|
240
|
+
return best
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _explicit_cost(net: Network, g: str) -> int:
|
|
245
|
+
devs = net.devices_in(g)
|
|
246
|
+
inside = set(devs)
|
|
247
|
+
return (len({d[0] for d in devs}) + len(_subtree(net, g))
|
|
248
|
+
+ sum(1 for l in net.links if l.a in inside and l.b in inside)
|
|
249
|
+
+ sum(1 for d in devs if d in net.stp_priority or d in net.dhcp_off)
|
|
250
|
+
+ sum(1 for x in _subtree(net, g) if x in net.clusters))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def candidates(net: Network, limits: Limits) -> list[Substitution]:
|
|
254
|
+
"""Every reuse the network allows, best saving first (deterministic order)."""
|
|
255
|
+
out: list[Substitution] = []
|
|
256
|
+
groups = [g for g in net.groups if len(net.devices_in(g)) <= limits.max_group_size]
|
|
257
|
+
for b in groups:
|
|
258
|
+
for a in groups:
|
|
259
|
+
if a == b or a in _subtree(net, b) or b in _subtree(net, a):
|
|
260
|
+
continue
|
|
261
|
+
if set(net.devices_in(a)) & set(net.devices_in(b)):
|
|
262
|
+
continue
|
|
263
|
+
phi = _clone_map(net, a, b)
|
|
264
|
+
if phi is not None and _clone_equivalent(net, a, b, phi):
|
|
265
|
+
saved = _explicit_cost(net, b) - 1
|
|
266
|
+
out.append(Substitution("clone", a, b, saves=saved))
|
|
267
|
+
rw = _best_rewire(net, a, b, phi)
|
|
268
|
+
if rw:
|
|
269
|
+
out.append(Substitution("copy", a, b, rw[0], rw[1], saved + sum(rw[1].values())))
|
|
270
|
+
da, db = net.devices_in(a), net.devices_in(b)
|
|
271
|
+
if len(da) == len(db) and all(net.devices[x].kind == net.devices[y].kind for x, y in zip(da, db)):
|
|
272
|
+
rw = _best_rewire(net, a, b, dict(zip(da, db)))
|
|
273
|
+
if rw and sum(rw[1].values()) > 1:
|
|
274
|
+
out.append(Substitution("ports", a, b, rw[0], rw[1], sum(rw[1].values()) - 1))
|
|
275
|
+
rank = {"copy": 0, "clone": 1, "ports": 2}
|
|
276
|
+
out.sort(key=lambda s: (-s.saves, rank[s.kind], list(net.groups).index(s.dst), s.src))
|
|
277
|
+
return out
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------------------- rendering
|
|
281
|
+
def render(net: Network, subs: list[Substitution], mode: str = "readable") -> tuple[list[str], list[str]]:
|
|
282
|
+
"""The script (lines) for a plan, and the properties the DSL can't express.
|
|
283
|
+
|
|
284
|
+
Raises _Infeasible if the substitutions can't be ordered into a valid script."""
|
|
285
|
+
comments = mode != "shortest"
|
|
286
|
+
lines: list[str] = []
|
|
287
|
+
limitations: list[str] = []
|
|
288
|
+
|
|
289
|
+
def say(text: str) -> None:
|
|
290
|
+
if comments:
|
|
291
|
+
lines.append(f"# {text}")
|
|
292
|
+
|
|
293
|
+
def blank() -> None:
|
|
294
|
+
if comments and lines and lines[-1]:
|
|
295
|
+
lines.append("")
|
|
296
|
+
|
|
297
|
+
def drop_empty_heading() -> None:
|
|
298
|
+
"""Remove a trailing heading (and blank line) that no command followed."""
|
|
299
|
+
while lines and (lines[-1].startswith("#") or not lines[-1]):
|
|
300
|
+
lines.pop()
|
|
301
|
+
|
|
302
|
+
clones = sorted((s for s in subs if s.kind in ("clone", "copy")),
|
|
303
|
+
key=lambda s: min(_order(d) for d in net.devices_in(s.dst)))
|
|
304
|
+
copies = [s for s in subs if s.kind == "ports"]
|
|
305
|
+
produced: dict[str, Substitution] = {d: s for s in clones for d in net.devices_in(s.dst)}
|
|
306
|
+
produced_groups = {g for s in clones for g in _subtree(net, s.dst)}
|
|
307
|
+
|
|
308
|
+
# Which cables the substitutions make; the rest are written out.
|
|
309
|
+
made: set[int] = set()
|
|
310
|
+
for i, l in enumerate(net.links):
|
|
311
|
+
sa, sb = produced.get(l.a), produced.get(l.b)
|
|
312
|
+
if sa is not None and sa is sb:
|
|
313
|
+
made.add(i) # inside a clone: copied with its ports
|
|
314
|
+
for s in [c for c in clones if c.kind == "copy"] + copies:
|
|
315
|
+
for (x, y, role), n in s.claims.items():
|
|
316
|
+
for i, l in enumerate(net.links):
|
|
317
|
+
if n and i not in made and l.role == role and {l.a, l.b} == {x, y}:
|
|
318
|
+
made.add(i)
|
|
319
|
+
n -= 1
|
|
320
|
+
if n:
|
|
321
|
+
raise _Infeasible(f"{s.command()} claims cables that aren't there")
|
|
322
|
+
|
|
323
|
+
present = {k: {d.num: d for d in net.devices.values() if d.kind == k} for k in KINDS}
|
|
324
|
+
top = {k: max([net.counters[k], *present[k]]) for k in KINDS}
|
|
325
|
+
nxt = {k: 1 for k in KINDS}
|
|
326
|
+
created: set[str] = set()
|
|
327
|
+
done_groups: set[str] = set(produced_groups)
|
|
328
|
+
done_links: set[int] = set(made)
|
|
329
|
+
configured: set[str] = set(produced) # clones copy settings
|
|
330
|
+
|
|
331
|
+
# ---- devices, in id order (gaps recreated with placeholders) ----------
|
|
332
|
+
def create(k: str, upto: int) -> None:
|
|
333
|
+
n = nxt[k]
|
|
334
|
+
while n <= upto:
|
|
335
|
+
if f"{k}{n}" in produced:
|
|
336
|
+
raise _Infeasible("a clone's ids would be taken by explicit devices")
|
|
337
|
+
m = n
|
|
338
|
+
if n in present[k]:
|
|
339
|
+
ports = present[k][n].nports
|
|
340
|
+
while (m + 1 <= upto and m + 1 in present[k] and present[k][m + 1].nports == ports
|
|
341
|
+
and m + 1 - n < 100 and f"{k}{m + 1}" not in produced):
|
|
342
|
+
m += 1
|
|
343
|
+
lines.append(f"add {k} {ports} {m - n + 1}")
|
|
344
|
+
created.update(f"{k}{i}" for i in range(n, m + 1))
|
|
345
|
+
else:
|
|
346
|
+
while m + 1 <= upto and m + 1 not in present[k] and m + 1 - n < 100:
|
|
347
|
+
m += 1
|
|
348
|
+
ids = [f"{k}{i}" for i in range(n, m + 1)]
|
|
349
|
+
say(f"{' '.join(ids)} {'was' if len(ids) == 1 else 'were'} deleted: "
|
|
350
|
+
f"create and remove placeholders so later ids match")
|
|
351
|
+
lines.append(f"add {k} 1 {len(ids)}")
|
|
352
|
+
lines.append("del " + " ".join(ids))
|
|
353
|
+
n = m + 1
|
|
354
|
+
nxt[k] = max(nxt[k], upto + 1)
|
|
355
|
+
|
|
356
|
+
# ---- per-device settings ------------------------------------------------
|
|
357
|
+
def ip_commands(d: str) -> list[str]:
|
|
358
|
+
dev = net.devices[d]
|
|
359
|
+
out = []
|
|
360
|
+
if dev.kind == "s":
|
|
361
|
+
return out
|
|
362
|
+
for p in range(1, dev.nports + 1):
|
|
363
|
+
mode_, static = iface_config(net, d, p)
|
|
364
|
+
if (mode_, static) == (DEFAULT_MODE[dev.kind], None):
|
|
365
|
+
continue
|
|
366
|
+
where = f"{d}:{p}"
|
|
367
|
+
if mode_ == "static" and static:
|
|
368
|
+
n_, h, gw = static
|
|
369
|
+
out.append(f"ip {where} ({n_},{h})" + (f" {gw}" if gw is not None and dev.kind == "c" else ""))
|
|
370
|
+
elif mode_ in ("none", "adhoc") and (mode_ == "none" or dev.kind == "c"):
|
|
371
|
+
out.append(f"ip {where} {mode_}")
|
|
372
|
+
elif mode_ == "auto" and dev.kind != "c":
|
|
373
|
+
continue
|
|
374
|
+
else:
|
|
375
|
+
limitations.append(f"interface {where} mode {mode_!r} can't be set from the console")
|
|
376
|
+
return out
|
|
377
|
+
|
|
378
|
+
def settings(devs: list[str], stp_now: bool) -> tuple[list[str], list[str]]:
|
|
379
|
+
"""(commands, switches to turn STP off for) for devices not yet configured."""
|
|
380
|
+
cmds, off = [], []
|
|
381
|
+
for d in sorted(devs, key=_order):
|
|
382
|
+
if d in configured:
|
|
383
|
+
continue
|
|
384
|
+
configured.add(d)
|
|
385
|
+
cmds += ip_commands(d)
|
|
386
|
+
if d in net.stp_priority:
|
|
387
|
+
cmds.append(f"stp priority {d} {net.stp_priority[d]}")
|
|
388
|
+
if d in net.dhcp_off:
|
|
389
|
+
cmds.append(f"dhcpd {d} off")
|
|
390
|
+
if d in net.stp_off:
|
|
391
|
+
off.append(d)
|
|
392
|
+
if stp_now and off:
|
|
393
|
+
cmds.append(f"stp {' '.join(off)} off")
|
|
394
|
+
off = []
|
|
395
|
+
return cmds, off
|
|
396
|
+
|
|
397
|
+
def group_cmds(roots: list[str]) -> list[str]:
|
|
398
|
+
out = []
|
|
399
|
+
for root in roots:
|
|
400
|
+
for g in _postorder(net, root):
|
|
401
|
+
if g in done_groups:
|
|
402
|
+
continue
|
|
403
|
+
done_groups.add(g)
|
|
404
|
+
out.append(f"group {g} {' '.join(net.groups[g])}")
|
|
405
|
+
if g in net.clusters:
|
|
406
|
+
router = next((m for m in net.groups[g] if m in net.devices and net.is_router(m)), None)
|
|
407
|
+
if router:
|
|
408
|
+
out.append(f"cluster {g} {router} policy={net.clusters[g]}")
|
|
409
|
+
else:
|
|
410
|
+
limitations.append(f"cluster {g} has no router directly in it, so `cluster` "
|
|
411
|
+
f"can't mark it (policy {net.clusters[g]})")
|
|
412
|
+
return out
|
|
413
|
+
|
|
414
|
+
def cable(i: int) -> str:
|
|
415
|
+
l = net.links[i]
|
|
416
|
+
return f"{l.a}:{l.pa} {l.b}:{l.pb}" + (f" role={l.role}" if l.role != "data" else "")
|
|
417
|
+
|
|
418
|
+
def cables_for(devs: set[str], others: set[str] | None = None) -> list[str]:
|
|
419
|
+
out = []
|
|
420
|
+
for i, l in enumerate(net.links):
|
|
421
|
+
if i in done_links:
|
|
422
|
+
continue
|
|
423
|
+
inside = l.a in devs and l.b in devs
|
|
424
|
+
crossing = others is not None and ((l.a in devs and l.b in others) or (l.b in devs and l.a in others))
|
|
425
|
+
if inside or crossing:
|
|
426
|
+
done_links.add(i)
|
|
427
|
+
out.append(cable(i))
|
|
428
|
+
return out
|
|
429
|
+
|
|
430
|
+
# ---- the plan ------------------------------------------------------------
|
|
431
|
+
pending = list(clones)
|
|
432
|
+
phase = 0
|
|
433
|
+
while True:
|
|
434
|
+
limit = {k: min([min(int(d[1:]) for d in net.devices_in(c.dst) if d[0] == k)
|
|
435
|
+
for c in pending if any(d[0] == k for d in net.devices_in(c.dst))],
|
|
436
|
+
default=top[k] + 1) - 1 for k in KINDS}
|
|
437
|
+
before = len(lines)
|
|
438
|
+
if any(nxt[k] <= limit[k] for k in KINDS):
|
|
439
|
+
blank()
|
|
440
|
+
say("Devices: add <kind> <ports> <count> creates the next ids of that kind" if phase == 0
|
|
441
|
+
else "More devices, up to the next reused group's ids")
|
|
442
|
+
for k in KINDS:
|
|
443
|
+
create(k, limit[k])
|
|
444
|
+
phase += 1 if len(lines) > before else 0
|
|
445
|
+
if not pending:
|
|
446
|
+
break
|
|
447
|
+
c = pending.pop(0)
|
|
448
|
+
dst = net.devices_in(c.dst)
|
|
449
|
+
for k in KINDS:
|
|
450
|
+
ids = sorted((int(d[1:]) for d in dst if d[0] == k))
|
|
451
|
+
if ids and nxt[k] != ids[0]:
|
|
452
|
+
raise _Infeasible(f"{c.dst}'s {KINDS[k][0]} ids don't follow on")
|
|
453
|
+
src = net.devices_in(c.src)
|
|
454
|
+
if not set(src) <= created:
|
|
455
|
+
raise _Infeasible(f"{c.src} isn't built before {c.dst}")
|
|
456
|
+
# Finish the source (groups, internal cables, settings) and, for copy, its endpoints.
|
|
457
|
+
roots = [c.src] + [g for pair in c.pairs for g in pair]
|
|
458
|
+
need = {d for g in roots for d in net.devices_in(g)}
|
|
459
|
+
if not need <= created:
|
|
460
|
+
raise _Infeasible(f"endpoints of {c.command()} aren't built yet")
|
|
461
|
+
blank()
|
|
462
|
+
say(f"Finish {c.src}" + (" and the groups its copy connects to" if c.pairs else "")
|
|
463
|
+
+ " so it can be reused")
|
|
464
|
+
mark = len(lines)
|
|
465
|
+
lines.extend(group_cmds(roots))
|
|
466
|
+
cmds, _ = settings(src, stp_now=True)
|
|
467
|
+
lines.extend(cmds)
|
|
468
|
+
ends = {d for _, f in c.pairs for d in net.devices_in(f)} | {d for e, _ in c.pairs for d in net.devices_in(e)}
|
|
469
|
+
lines.extend(cables_for(set(src), ends if c.kind == "copy" else None))
|
|
470
|
+
if len(lines) == mark:
|
|
471
|
+
drop_empty_heading()
|
|
472
|
+
blank()
|
|
473
|
+
say(c.describe())
|
|
474
|
+
lines.append(c.command())
|
|
475
|
+
created.update(dst)
|
|
476
|
+
for k in KINDS:
|
|
477
|
+
ids = [int(d[1:]) for d in dst if d[0] == k]
|
|
478
|
+
if ids:
|
|
479
|
+
nxt[k] = max(ids) + 1
|
|
480
|
+
|
|
481
|
+
# ---- everything else ------------------------------------------------------
|
|
482
|
+
roots = [g for g in net.groups if net.parent_of(g) is None]
|
|
483
|
+
gcmds = group_cmds(roots)
|
|
484
|
+
if gcmds:
|
|
485
|
+
blank()
|
|
486
|
+
say("Groups, innermost first (group <name> <members…>)")
|
|
487
|
+
lines.extend(gcmds)
|
|
488
|
+
cmds, off = settings([d for d in net.devices if d not in configured], stp_now=False)
|
|
489
|
+
ipc = [c for c in cmds if c.startswith("ip ")]
|
|
490
|
+
other = [c for c in cmds if not c.startswith("ip ")]
|
|
491
|
+
if ipc:
|
|
492
|
+
blank()
|
|
493
|
+
say("Interface settings (computers use DHCP and router ports set themselves up otherwise)")
|
|
494
|
+
lines.append("mode addr")
|
|
495
|
+
lines.extend(ipc)
|
|
496
|
+
rest = [i for i in range(len(net.links)) if i not in done_links]
|
|
497
|
+
if rest:
|
|
498
|
+
blank()
|
|
499
|
+
say("Cables, on their exact ports: <device>:<port> <device>:<port> [role=…]")
|
|
500
|
+
if mode == "readable": # by the top-level group they belong to
|
|
501
|
+
top_of = {d: (net.chain(d) or [None])[0] for d in net.devices}
|
|
502
|
+
order = [g for g in net.groups if net.parent_of(g) is None]
|
|
503
|
+
buckets: dict[str | None, list[int]] = {}
|
|
504
|
+
for i in rest:
|
|
505
|
+
l = net.links[i]
|
|
506
|
+
key = top_of[l.a] if top_of[l.a] == top_of[l.b] else None
|
|
507
|
+
buckets.setdefault(key, []).append(i)
|
|
508
|
+
for key in [g for g in order if g in buckets] + ([None] if None in buckets else []):
|
|
509
|
+
if len(buckets) > 1:
|
|
510
|
+
say(f"inside {key}" if key else "between groups")
|
|
511
|
+
lines.extend(cable(i) for i in buckets[key])
|
|
512
|
+
else:
|
|
513
|
+
lines.extend(cable(i) for i in rest)
|
|
514
|
+
done_links.update(rest)
|
|
515
|
+
for s in copies:
|
|
516
|
+
blank()
|
|
517
|
+
say(s.describe())
|
|
518
|
+
lines.append(s.command())
|
|
519
|
+
if other or off:
|
|
520
|
+
blank()
|
|
521
|
+
say("Switch and router settings")
|
|
522
|
+
lines.extend(other)
|
|
523
|
+
if off:
|
|
524
|
+
lines.append(f"stp {' '.join(off)} off")
|
|
525
|
+
down_links = [i for i, l in enumerate(net.links) if not l.up]
|
|
526
|
+
down_devs = [d for d in net.sorted_devices() if not d.up]
|
|
527
|
+
if down_links or down_devs:
|
|
528
|
+
blank()
|
|
529
|
+
say("Runtime state: failures")
|
|
530
|
+
lines.extend(f"fail {net.links[i].a}:{net.links[i].pa}" for i in down_links)
|
|
531
|
+
lines.extend(f"fail {d.id}" for d in down_devs)
|
|
532
|
+
return lines, sorted(set(limitations))
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# ---------------------------------------------------------------------------- verification
|
|
536
|
+
def _ignore(limitations: list[str]) -> set[str]:
|
|
537
|
+
"""Canonical keys the DSL can't express (masked out of the comparison, and reported)."""
|
|
538
|
+
out = set()
|
|
539
|
+
for text in limitations:
|
|
540
|
+
if text.startswith("cluster "):
|
|
541
|
+
out.add(f"clusters {text.split()[1]}")
|
|
542
|
+
elif text.startswith("interface "):
|
|
543
|
+
out.add(f"ifaces {text.split()[1]}")
|
|
544
|
+
return out
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def verify(net: Network, script: str, limitations: list[str] | None = None) -> list[str]:
|
|
548
|
+
"""Replay a script into a fresh network and compare. [] means equivalent."""
|
|
549
|
+
from .scripting import replay
|
|
550
|
+
try:
|
|
551
|
+
got = replay(script)
|
|
552
|
+
except NetError as e:
|
|
553
|
+
return [f"replay failed: {e}"]
|
|
554
|
+
masked = _ignore(limitations or [])
|
|
555
|
+
return [d for d in differences(net, got, limit=1000)
|
|
556
|
+
if not any(d.startswith(m + ":") for m in masked)][:8]
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _header(net: Network, mode: str, body: list[str], explicit: int, used: list[Substitution],
|
|
560
|
+
limitations: list[str]) -> list[str]:
|
|
561
|
+
n = _commands(body)
|
|
562
|
+
out = [f"# ByteSim construction script ({mode}) — {len(net.devices)} devices, "
|
|
563
|
+
f"{len(net.links)} cables, {len(net.groups)} groups in {n} commands"]
|
|
564
|
+
if mode != "shortest":
|
|
565
|
+
out += ["# Replay it into an empty network: `clear`, then `run <this file>`.",
|
|
566
|
+
"# Rebuilds: device ids and port counts, groups, cables (exact ports) and roles,",
|
|
567
|
+
"# HA clusters, STP / DHCP-server / interface settings, failures.",
|
|
568
|
+
"# Not rebuilt: positions (auto-arranged), learned addresses (re-learned),",
|
|
569
|
+
"# MAC tables, storms."]
|
|
570
|
+
if used:
|
|
571
|
+
out.append(f"# Reuses structure {len(used)} time{'s' if len(used) != 1 else ''}: "
|
|
572
|
+
f"{explicit} commands without it.")
|
|
573
|
+
for text in limitations:
|
|
574
|
+
out.append(f"# NOT EXPRESSIBLE: {text}")
|
|
575
|
+
out.append("# Verified: replaying this script rebuilds an equivalent network.")
|
|
576
|
+
return out
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
# ---------------------------------------------------------------------------- the entry point
|
|
580
|
+
def synthesize(net: Network, mode: str = "readable", limits: Limits | None = None) -> Synthesis:
|
|
581
|
+
"""A verified construction script for ``net``. Raises NetError if none can be made."""
|
|
582
|
+
if mode not in MODES:
|
|
583
|
+
raise NetError(f"mode must be one of: {', '.join(MODES)}")
|
|
584
|
+
limits = limits or Limits()
|
|
585
|
+
body, limitations = render(net, [], mode)
|
|
586
|
+
problems = verify(net, "\n".join(body), limitations)
|
|
587
|
+
if problems:
|
|
588
|
+
raise NetError("couldn't build a script that rebuilds this network: " + "; ".join(problems))
|
|
589
|
+
explicit = _commands(body)
|
|
590
|
+
used: list[Substitution] = []
|
|
591
|
+
tried = rejected = 0
|
|
592
|
+
if mode != "explicit":
|
|
593
|
+
floor = READABLE_MIN_SAVING if mode == "readable" else 1
|
|
594
|
+
start = time.monotonic()
|
|
595
|
+
for cand in candidates(net, limits):
|
|
596
|
+
if tried >= limits.max_candidates or time.monotonic() - start > limits.max_seconds:
|
|
597
|
+
break
|
|
598
|
+
if cand.saves < floor or _conflicts(net, used, cand):
|
|
599
|
+
continue
|
|
600
|
+
if mode == "readable" and _first(net, cand.dst) < _first(net, cand.src):
|
|
601
|
+
continue # readable: later groups follow earlier ones, not back
|
|
602
|
+
tried += 1
|
|
603
|
+
try:
|
|
604
|
+
trial, lim = render(net, used + [cand], mode)
|
|
605
|
+
except _Infeasible:
|
|
606
|
+
rejected += 1
|
|
607
|
+
continue
|
|
608
|
+
if _cost(trial) >= _cost(body) or verify(net, "\n".join(trial), lim):
|
|
609
|
+
rejected += 1
|
|
610
|
+
continue
|
|
611
|
+
used.append(cand)
|
|
612
|
+
body, limitations = trial, lim
|
|
613
|
+
lines = _header(net, mode, body, explicit, used, limitations) + ([""] if mode != "shortest" else []) + body
|
|
614
|
+
while lines and not lines[-1]:
|
|
615
|
+
lines.pop()
|
|
616
|
+
return Synthesis("\n".join(lines) + "\n", mode, _commands(body), explicit, used, limitations,
|
|
617
|
+
tried, rejected)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _first(net: Network, g: str) -> tuple[int, int]:
|
|
621
|
+
return min(_order(d) for d in net.devices_in(g))
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _conflicts(net: Network, used: list[Substitution], cand: Substitution) -> bool:
|
|
625
|
+
"""Two reuses can't both make the same devices or the same cables."""
|
|
626
|
+
def devs(s: Substitution) -> set[str]:
|
|
627
|
+
return set(net.devices_in(s.dst)) if s.kind in ("clone", "copy") else set()
|
|
628
|
+
|
|
629
|
+
mine = devs(cand)
|
|
630
|
+
claimed: Counter = Counter()
|
|
631
|
+
for s in used:
|
|
632
|
+
if mine & devs(s) or cand.dst == s.dst and cand.kind != "ports" and s.kind != "ports":
|
|
633
|
+
return True
|
|
634
|
+
if cand.kind in ("clone", "copy") and s.kind in ("clone", "copy") and (
|
|
635
|
+
cand.dst in _subtree(net, s.dst) or s.dst in _subtree(net, cand.dst)):
|
|
636
|
+
return True
|
|
637
|
+
claimed += s.claims
|
|
638
|
+
if cand.claims:
|
|
639
|
+
have = Counter()
|
|
640
|
+
for l in net.links:
|
|
641
|
+
have[(l.a, l.b, l.role)] += 1
|
|
642
|
+
have[(l.b, l.a, l.role)] += 1
|
|
643
|
+
return bool((claimed + cand.claims) - have)
|
|
644
|
+
return False
|