@takodotid/circuit 0.34.2 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/README.md +13 -14
  2. package/dist/chunks/index-20kcjxjg.js +174 -0
  3. package/dist/chunks/index-2r4bbhac.js +977 -0
  4. package/dist/chunks/index-n7w3tg5y.js +149 -0
  5. package/dist/chunks/new-w3zys5y4.js +172 -0
  6. package/dist/chunks/server-gzsf5me5.js +259 -0
  7. package/dist/cli/index.js +11347 -9018
  8. package/dist/helpers/index.js +594 -0
  9. package/dist/index.js +9 -28
  10. package/dist/types/core/{addresses.d.ts → address-space.d.ts} +23 -7
  11. package/dist/types/core/address.d.ts +44 -0
  12. package/dist/types/core/cabling.d.ts +41 -0
  13. package/dist/types/core/cross-checks.d.ts +28 -0
  14. package/dist/types/core/define.d.ts +25 -3
  15. package/dist/types/core/project-files.d.ts +12 -0
  16. package/dist/types/core/schema-docs.d.ts +13 -4
  17. package/dist/types/core/secrets.d.ts +62 -21
  18. package/dist/types/core/{graph.d.ts → topology.d.ts} +32 -12
  19. package/dist/types/core/utils.d.ts +8 -0
  20. package/dist/types/core/validator.d.ts +19 -0
  21. package/dist/types/helpers/bgp.d.ts +97 -0
  22. package/dist/types/helpers/checks.d.ts +32 -0
  23. package/dist/types/helpers/exchange.d.ts +49 -0
  24. package/dist/types/helpers/filters.d.ts +51 -0
  25. package/dist/types/helpers/index.d.ts +10 -0
  26. package/dist/types/helpers/route-server.d.ts +52 -0
  27. package/dist/types/index.d.ts +5 -3
  28. package/dist/types/integrations/librenms.d.ts +60 -0
  29. package/dist/types/integrations/netbox.d.ts +89 -0
  30. package/dist/types/integrations/peeringdb.d.ts +24 -0
  31. package/dist/types/integrations/prometheus.d.ts +24 -0
  32. package/dist/types/integrations/registries.d.ts +8 -0
  33. package/dist/types/platforms/bgp-wait.d.ts +18 -0
  34. package/dist/types/platforms/catalog.d.ts +109 -0
  35. package/dist/types/platforms/derive.d.ts +17 -0
  36. package/dist/types/platforms/fortios/firewall.d.ts +45 -0
  37. package/dist/types/platforms/fortios/index.d.ts +134 -0
  38. package/dist/types/platforms/fortios/names.d.ts +52 -0
  39. package/dist/types/platforms/fortios/plan.d.ts +104 -0
  40. package/dist/types/platforms/fortios/render.d.ts +75 -0
  41. package/dist/types/platforms/fortios/routing.d.ts +56 -0
  42. package/dist/types/platforms/fortios/tree.d.ts +37 -0
  43. package/dist/types/platforms/index.d.ts +13 -0
  44. package/dist/types/platforms/junos/bgp.d.ts +44 -0
  45. package/dist/types/platforms/junos/index.d.ts +114 -0
  46. package/dist/types/platforms/junos/render.d.ts +154 -0
  47. package/dist/types/platforms/line-diff.d.ts +67 -0
  48. package/dist/types/platforms/platform.d.ts +120 -0
  49. package/dist/types/platforms/raisecom-ros/index.d.ts +71 -0
  50. package/dist/types/platforms/raisecom-ros/render.d.ts +41 -0
  51. package/dist/types/platforms/routeros/export.d.ts +59 -0
  52. package/dist/types/platforms/routeros/index.d.ts +96 -0
  53. package/dist/types/{adapters/devices → platforms}/routeros/models.d.ts +4 -0
  54. package/dist/types/platforms/routeros/plan.d.ts +54 -0
  55. package/dist/types/platforms/routeros/render.d.ts +240 -0
  56. package/dist/types/platforms/vrp/index.d.ts +83 -0
  57. package/dist/types/platforms/vrp/render.d.ts +96 -0
  58. package/dist/types/schema/common.d.ts +12 -6
  59. package/dist/types/schema/index.d.ts +1 -0
  60. package/dist/types/schema/interfaces.d.ts +14 -6
  61. package/dist/types/schema/policy.d.ts +18 -10
  62. package/dist/types/schema/routing.d.ts +14 -1
  63. package/dist/types/schema/system.d.ts +16 -6
  64. package/dist/types/schema/time-zones.d.ts +2 -0
  65. package/dist/types/transport/console.d.ts +79 -23
  66. package/dist/types/transport/path-check.d.ts +32 -0
  67. package/dist/types/transport/ssh.d.ts +17 -15
  68. package/package.json +17 -11
  69. package/src/cli/graph-format.ts +91 -0
  70. package/src/cli/index.ts +1074 -484
  71. package/src/cli/new.ts +208 -175
  72. package/src/cli/rename.ts +97 -91
  73. package/src/core/address-space.ts +320 -0
  74. package/src/core/address.ts +202 -0
  75. package/src/core/cabling.ts +154 -0
  76. package/src/core/cross-checks.ts +364 -0
  77. package/src/core/define.ts +26 -3
  78. package/src/core/project-files.ts +35 -0
  79. package/src/core/schema-docs.ts +138 -130
  80. package/src/core/secrets.ts +170 -76
  81. package/src/core/topology.ts +656 -0
  82. package/src/core/utils.ts +37 -0
  83. package/src/core/validator.ts +407 -0
  84. package/src/helpers/bgp.ts +301 -0
  85. package/src/helpers/checks.ts +178 -0
  86. package/src/helpers/exchange.ts +117 -0
  87. package/src/helpers/filters.ts +107 -0
  88. package/src/helpers/index.ts +12 -0
  89. package/src/helpers/route-server.ts +212 -0
  90. package/src/index.ts +6 -3
  91. package/src/integrations/librenms.ts +291 -0
  92. package/src/integrations/netbox.ts +620 -0
  93. package/src/integrations/peeringdb.ts +173 -0
  94. package/src/integrations/prometheus.ts +90 -0
  95. package/src/integrations/registries.ts +25 -0
  96. package/src/platforms/bgp-wait.ts +41 -0
  97. package/src/platforms/catalog.ts +69 -0
  98. package/src/platforms/derive.ts +117 -0
  99. package/src/{adapters/devices → platforms}/fortios/TRAPS.md +6 -0
  100. package/src/platforms/fortios/firewall.ts +377 -0
  101. package/src/platforms/fortios/index.ts +1060 -0
  102. package/src/platforms/fortios/names.ts +130 -0
  103. package/src/platforms/fortios/plan.ts +929 -0
  104. package/src/platforms/fortios/render.ts +779 -0
  105. package/src/platforms/fortios/routing.ts +476 -0
  106. package/src/platforms/fortios/tree.ts +129 -0
  107. package/src/platforms/index.ts +36 -0
  108. package/src/{adapters/devices → platforms}/junos/TRAPS.md +8 -0
  109. package/src/platforms/junos/bgp.ts +293 -0
  110. package/src/platforms/junos/index.ts +1006 -0
  111. package/src/platforms/junos/render.ts +967 -0
  112. package/src/platforms/line-diff.ts +244 -0
  113. package/src/platforms/platform.ts +122 -0
  114. package/src/platforms/raisecom-ros/TRAPS.md +40 -0
  115. package/src/platforms/raisecom-ros/index.ts +731 -0
  116. package/src/platforms/raisecom-ros/render.ts +328 -0
  117. package/src/{adapters/devices → platforms}/routeros/TRAPS.md +16 -2
  118. package/src/platforms/routeros/export.ts +342 -0
  119. package/src/platforms/routeros/index.ts +636 -0
  120. package/src/{adapters/devices → platforms}/routeros/models.ts +2 -0
  121. package/src/platforms/routeros/plan.ts +444 -0
  122. package/src/platforms/routeros/render.ts +1483 -0
  123. package/src/platforms/vrp/TRAPS.md +45 -0
  124. package/src/platforms/vrp/index.ts +652 -0
  125. package/src/platforms/vrp/render.ts +615 -0
  126. package/src/schema/common.ts +15 -6
  127. package/src/schema/index.ts +1 -0
  128. package/src/schema/interfaces.ts +15 -5
  129. package/src/schema/policy.ts +22 -10
  130. package/src/schema/routing.ts +16 -1
  131. package/src/schema/system.ts +16 -6
  132. package/src/schema/time-zones.ts +482 -0
  133. package/src/studio/data.ts +172 -168
  134. package/src/studio/git.ts +95 -90
  135. package/src/studio/public/app.js +161 -150
  136. package/src/studio/public/compare.js +219 -212
  137. package/src/studio/public/diff.js +77 -75
  138. package/src/studio/public/dom.js +118 -108
  139. package/src/studio/public/evaluate.js +584 -545
  140. package/src/studio/public/export.js +120 -118
  141. package/src/studio/public/graph.js +1343 -1266
  142. package/src/studio/public/net.js +115 -109
  143. package/src/studio/public/tables.js +1135 -1083
  144. package/src/studio/public/tools.js +561 -529
  145. package/src/studio/server.ts +202 -196
  146. package/src/transport/console.ts +363 -271
  147. package/src/transport/path-check.ts +91 -0
  148. package/src/transport/ssh.ts +98 -94
  149. package/templates/colocation/checks.ts +11 -6
  150. package/templates/colocation/edge-01.ts +9 -9
  151. package/templates/colocation/routing.ts +2 -2
  152. package/templates/colocation/tenancy.ts +3 -3
  153. package/templates/colocation/tor-01.ts +3 -3
  154. package/templates/edge-router/checks.ts +4 -4
  155. package/templates/edge-router/edge-01.ts +5 -5
  156. package/templates/edge-router/routing.ts +2 -2
  157. package/templates/project/AGENTS.md +5 -1
  158. package/templates/single-site/checks.ts +2 -2
  159. package/dist/chunks/index-47debt16.js +0 -956
  160. package/dist/chunks/index-v8zgr7wm.js +0 -128
  161. package/dist/chunks/new-ny89s7y7.js +0 -166
  162. package/dist/chunks/server-tjbb3rqc.js +0 -251
  163. package/dist/presets/index.js +0 -394
  164. package/dist/types/adapters/devices/catalog.d.ts +0 -86
  165. package/dist/types/adapters/devices/derive.d.ts +0 -7
  166. package/dist/types/adapters/devices/fortios/firewall.d.ts +0 -23
  167. package/dist/types/adapters/devices/fortios/index.d.ts +0 -58
  168. package/dist/types/adapters/devices/fortios/names.d.ts +0 -48
  169. package/dist/types/adapters/devices/fortios/plan.d.ts +0 -28
  170. package/dist/types/adapters/devices/fortios/render.d.ts +0 -38
  171. package/dist/types/adapters/devices/fortios/routing.d.ts +0 -32
  172. package/dist/types/adapters/devices/fortios/tree.d.ts +0 -33
  173. package/dist/types/adapters/devices/index.d.ts +0 -3
  174. package/dist/types/adapters/devices/junos/bgp.d.ts +0 -11
  175. package/dist/types/adapters/devices/junos/index.d.ts +0 -42
  176. package/dist/types/adapters/devices/junos/render.d.ts +0 -52
  177. package/dist/types/adapters/devices/lines.d.ts +0 -56
  178. package/dist/types/adapters/devices/raisecom-ros/index.d.ts +0 -28
  179. package/dist/types/adapters/devices/routeros/export.d.ts +0 -39
  180. package/dist/types/adapters/devices/routeros/index.d.ts +0 -30
  181. package/dist/types/adapters/devices/routeros/plan.d.ts +0 -4
  182. package/dist/types/adapters/devices/routeros/render.d.ts +0 -100
  183. package/dist/types/adapters/devices/types.d.ts +0 -40
  184. package/dist/types/adapters/devices/vrp/index.d.ts +0 -27
  185. package/dist/types/adapters/registries/index.d.ts +0 -3
  186. package/dist/types/adapters/registries/librenms.d.ts +0 -15
  187. package/dist/types/adapters/registries/peeringdb.d.ts +0 -16
  188. package/dist/types/adapters/registries/prometheus.d.ts +0 -16
  189. package/dist/types/core/across.d.ts +0 -4
  190. package/dist/types/core/addr.d.ts +0 -34
  191. package/dist/types/core/files.d.ts +0 -6
  192. package/dist/types/core/merge.d.ts +0 -4
  193. package/dist/types/core/time.d.ts +0 -2
  194. package/dist/types/core/validate.d.ts +0 -2
  195. package/dist/types/presets/index.d.ts +0 -183
  196. package/src/adapters/devices/catalog.ts +0 -41
  197. package/src/adapters/devices/derive.ts +0 -59
  198. package/src/adapters/devices/fortios/firewall.ts +0 -362
  199. package/src/adapters/devices/fortios/index.ts +0 -923
  200. package/src/adapters/devices/fortios/names.ts +0 -115
  201. package/src/adapters/devices/fortios/plan.ts +0 -888
  202. package/src/adapters/devices/fortios/render.ts +0 -768
  203. package/src/adapters/devices/fortios/routing.ts +0 -444
  204. package/src/adapters/devices/fortios/tree.ts +0 -121
  205. package/src/adapters/devices/index.ts +0 -9
  206. package/src/adapters/devices/junos/bgp.ts +0 -269
  207. package/src/adapters/devices/junos/index.ts +0 -862
  208. package/src/adapters/devices/junos/render.ts +0 -872
  209. package/src/adapters/devices/lines.ts +0 -212
  210. package/src/adapters/devices/raisecom-ros/TRAPS.md +0 -25
  211. package/src/adapters/devices/raisecom-ros/index.ts +0 -693
  212. package/src/adapters/devices/routeros/export.ts +0 -321
  213. package/src/adapters/devices/routeros/index.ts +0 -346
  214. package/src/adapters/devices/routeros/plan.ts +0 -398
  215. package/src/adapters/devices/routeros/render.ts +0 -1490
  216. package/src/adapters/devices/types.ts +0 -38
  217. package/src/adapters/devices/vrp/TRAPS.md +0 -26
  218. package/src/adapters/devices/vrp/index.ts +0 -865
  219. package/src/adapters/registries/index.ts +0 -19
  220. package/src/adapters/registries/librenms.ts +0 -92
  221. package/src/adapters/registries/peeringdb.ts +0 -169
  222. package/src/adapters/registries/prometheus.ts +0 -71
  223. package/src/cli/graph.ts +0 -89
  224. package/src/core/across.ts +0 -350
  225. package/src/core/addr.ts +0 -174
  226. package/src/core/addresses.ts +0 -316
  227. package/src/core/files.ts +0 -32
  228. package/src/core/graph.ts +0 -640
  229. package/src/core/merge.ts +0 -19
  230. package/src/core/time.ts +0 -15
  231. package/src/core/validate.ts +0 -348
  232. package/src/presets/index.ts +0 -579
  233. /package/dist/types/{adapters/devices → platforms}/fortios/models.d.ts +0 -0
  234. /package/dist/types/{adapters/devices → platforms}/junos/models.d.ts +0 -0
  235. /package/dist/types/{adapters/devices → platforms}/raisecom-ros/models.d.ts +0 -0
  236. /package/dist/types/{adapters/devices → platforms}/vrp/models.d.ts +0 -0
  237. /package/src/{adapters/devices → platforms}/fortios/models.ts +0 -0
  238. /package/src/{adapters/devices → platforms}/junos/models.ts +0 -0
  239. /package/src/{adapters/devices → platforms}/raisecom-ros/models.ts +0 -0
  240. /package/src/{adapters/devices → platforms}/vrp/models.ts +0 -0
@@ -0,0 +1,444 @@
1
+ // What it takes to make a RouterOS device run exactly what was rendered. Everything the device holds that the render does not is removed or reset.
2
+ //
3
+ // Two phases. Additions and changes go first, in dependency order, so nothing ever points at something that does not exist yet. Removals go last, in reverse order, so nothing is removed while something still uses it.
4
+
5
+ import type { ApplyOptions, Plan, Step } from "../platform";
6
+ import { type Command, RouterOsExport } from "./export";
7
+
8
+ /** What is planned for one menu: changes for the first phase, removals for the last. */
9
+ type MenuPlan = { first: Step[]; last: Step[] };
10
+
11
+ export class RouterOsPlan {
12
+ /** A plan line the apply handles itself: upload a certificate and its key, and import them. */
13
+ static readonly INSTALL_CERTIFICATE = "#install-certificate ";
14
+
15
+ /** Dependency order. A menu not listed sorts after all of these. The longest matching prefix decides. */
16
+ private static readonly ORDER = [
17
+ // Certificates depend on nothing, and services name them.
18
+ "/certificate",
19
+ "/interface bridge",
20
+ "/interface ethernet",
21
+ "/interface bonding",
22
+ "/interface vlan",
23
+ "/interface macvlan",
24
+ "/interface gre",
25
+ "/interface vxlan",
26
+ "/interface wireguard",
27
+ "/interface vrrp",
28
+ "/interface bridge port",
29
+ "/interface bridge vlan",
30
+ "/interface bridge filter",
31
+ "/interface list",
32
+ "/ip vrf",
33
+ "/ip address",
34
+ "/ipv6 address",
35
+ "/ip pool",
36
+ "/ip dhcp-server",
37
+ "/ip firewall address-list",
38
+ "/ipv6 firewall address-list",
39
+ "/ip firewall",
40
+ "/ipv6 firewall",
41
+ "/routing rpki",
42
+ "/routing filter rule",
43
+ "/routing bgp instance",
44
+ "/routing bgp connection",
45
+ "/routing ospf instance",
46
+ "/routing ospf area",
47
+ "/routing ospf interface-template",
48
+ "/ip",
49
+ "/ipv6",
50
+ "/routing",
51
+ "/interface ethernet switch",
52
+ "/snmp",
53
+ "/tool",
54
+ "/user",
55
+ // A logging rule names its action, so the action exists first.
56
+ "/system logging action",
57
+ "/system",
58
+ ];
59
+
60
+ /** Menus that also hold entries the device learned rather than was told. A selector must not match those. */
61
+ private static readonly DYNAMIC = new Set([
62
+ "/ip route",
63
+ "/ipv6 route",
64
+ "/ip address",
65
+ "/ipv6 address",
66
+ "/ip firewall address-list",
67
+ "/ipv6 firewall address-list",
68
+ "/ip firewall filter",
69
+ "/ipv6 firewall filter",
70
+ "/ip firewall nat",
71
+ "/ipv6 firewall nat",
72
+ "/ip firewall mangle",
73
+ "/ipv6 firewall mangle",
74
+ "/ip firewall raw",
75
+ "/ipv6 firewall raw",
76
+ "/ip dhcp-server lease",
77
+ "/interface bridge vlan",
78
+ ]);
79
+
80
+ /** Menus where an entry that keeps its name but changes what it is known by is changed with a `set`, so what points at it by name, such as an address on a VLAN interface, stays attached. In every other menu it is removed and added again. */
81
+ private static readonly CHANGED_IN_PLACE = new Set(["/interface vlan"]);
82
+
83
+ /** The variable holding the old rules of an ordered menu while the new ones are added. Removed when the rewrite ends. */
84
+ private static readonly OLD_RULES = "frameworkOldRules";
85
+
86
+ /** What blocks an apply: something this plan cannot converge. */
87
+ private readonly problems: string[] = [];
88
+
89
+ /** Also send every secret, so a rotated value reaches the device. */
90
+ private constructor(private readonly sendSecrets: boolean) {}
91
+
92
+ /** What it takes to go from `current` to `desired`. */
93
+ static of(desired: string, current: string, options: Pick<ApplyOptions, "secrets">): Plan {
94
+ const want = RouterOsExport.parse(desired);
95
+ const have = RouterOsExport.parse(current);
96
+ const planner = new RouterOsPlan(options.secrets);
97
+
98
+ // Entries of lists filled by `refresh` belong to it, not to the render.
99
+ const owned = (command: Command) => !(command.menu.endsWith("address-list") && want.generated.has(command.attrs.list ?? ""));
100
+ const wantedByMenu = RouterOsPlan.byMenu(want.commands.filter(owned));
101
+ const existingByMenu = RouterOsPlan.byMenu(have.commands.filter(owned));
102
+
103
+ const menus = [...new Set([...wantedByMenu.keys(), ...existingByMenu.keys()])].sort(
104
+ (a, b) => RouterOsPlan.rank(a) - RouterOsPlan.rank(b) || a.localeCompare(b)
105
+ );
106
+ const first: Step[] = [];
107
+ const last: Step[] = [];
108
+
109
+ for (const menu of menus) {
110
+ const wanted = wantedByMenu.get(menu) ?? [];
111
+ const existing = existingByMenu.get(menu) ?? [];
112
+
113
+ let menuPlan: MenuPlan;
114
+ if (menu === "/certificate") menuPlan = RouterOsPlan.certificates(wanted, existing);
115
+ else if (RouterOsExport.ORDERED.has(menu)) menuPlan = RouterOsPlan.ordered(menu, wanted, existing);
116
+ else {
117
+ menuPlan = planner.entries(menu, wanted, existing);
118
+ const settings = planner.settings(menu, wanted, existing);
119
+ if (settings) menuPlan.first.unshift(settings);
120
+ }
121
+
122
+ first.push(...menuPlan.first);
123
+ // Removals run in reverse dependency order, so a menu's are prepended.
124
+ last.unshift(...menuPlan.last);
125
+ }
126
+
127
+ return { steps: [...first, ...last], problems: planner.problems };
128
+ }
129
+
130
+ private static rank(menu: string): number {
131
+ let best = RouterOsPlan.ORDER.length;
132
+ let bestLength = 0;
133
+ RouterOsPlan.ORDER.forEach((prefix, index) => {
134
+ const matches = menu === prefix || menu.startsWith(prefix + " ");
135
+ if (matches && prefix.length > bestLength) {
136
+ best = index;
137
+ bestLength = prefix.length;
138
+ }
139
+ });
140
+ return best;
141
+ }
142
+
143
+ /** Quote a value for a command. */
144
+ private static quote(value: string): string {
145
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$")}"`;
146
+ }
147
+
148
+ /** A value as a command takes it: bare when plain, quoted otherwise. A bare value is checked by `:parse` against the values the property takes; a quoted one is not (TRAPS.md). */
149
+ private static value(raw: string): string {
150
+ return /^[\w.:/@,!-]+$/.test(raw) ? raw : RouterOsPlan.quote(raw);
151
+ }
152
+
153
+ /** In a `find`, a quoted `yes` is a string and matches no boolean, while an unquoted slash matches nothing. Plain words go bare, anything else quoted. */
154
+ private static findValue(raw: string): string {
155
+ return /^[\w.:-]+$/.test(raw) ? raw : RouterOsPlan.quote(raw);
156
+ }
157
+
158
+ /** A selector for the entry with this identity. */
159
+ private static find(menu: string, id: Record<string, string>): string {
160
+ const conditions = Object.entries(id).map(([property, raw]) => `${property}=${RouterOsPlan.findValue(raw)}`);
161
+ if (RouterOsPlan.DYNAMIC.has(menu)) conditions.push("!dynamic");
162
+ return `[ find ${conditions.join(" and ")} ]`;
163
+ }
164
+
165
+ /** A whole command as sent: menu, verb, selector, flags, then properties. */
166
+ private static line(menu: string, verb: string, selector: string, flags: string[], attrs: Record<string, string>): string {
167
+ const properties = Object.entries(attrs).map(([property, raw]) => `${property}=${RouterOsPlan.value(raw)}`);
168
+ return [menu, verb, selector, ...flags, ...properties].filter(Boolean).join(" ");
169
+ }
170
+
171
+ private static describe(attrs: Record<string, string>): string {
172
+ return Object.entries(attrs)
173
+ .map(([property, raw]) => `${property}=${raw}`)
174
+ .join(" ");
175
+ }
176
+
177
+ /** Group commands by menu. */
178
+ private static byMenu(commands: Command[]): Map<string, Command[]> {
179
+ const groups = new Map<string, Command[]>();
180
+
181
+ for (const command of commands) {
182
+ const group = groups.get(command.menu) ?? [];
183
+ group.push(command);
184
+ groups.set(command.menu, group);
185
+ }
186
+
187
+ return groups;
188
+ }
189
+
190
+ /**
191
+ * The properties to send so that the device's entry matches the rendered one. A property the device holds and the render does not state is reset to its default.
192
+ */
193
+ private changes(wanted: Command, existing: Command | undefined, label: string): Record<string, string> {
194
+ const want = RouterOsExport.normal(wanted);
195
+ const have = existing ? RouterOsExport.normal(existing) : {};
196
+ const result: Record<string, string> = {};
197
+
198
+ for (const [property, desired] of Object.entries(want)) {
199
+ if (have[property] !== desired) result[property] = desired;
200
+ }
201
+
202
+ for (const property of Object.keys(have)) {
203
+ if (property in want) continue;
204
+
205
+ const clearedWith = RouterOsExport.CLEARED_WITH[wanted.menu]?.[property];
206
+ if (clearedWith && !(clearedWith in want)) continue;
207
+
208
+ const fallback = RouterOsExport.defaultOf(wanted, property);
209
+ if (fallback === undefined) {
210
+ this.problems.push(
211
+ `${label}: ${property}=${have[property]} is set on the device and its default is unknown; add it to DEFAULTS`
212
+ );
213
+ } else {
214
+ result[property] = fallback;
215
+ }
216
+ }
217
+
218
+ if (this.sendSecrets) {
219
+ for (const [property, raw] of Object.entries(wanted.attrs)) {
220
+ if (RouterOsExport.SECRET_KEYS.has(property) || RouterOsExport.isSecretMarker(raw)) result[property] = raw;
221
+ }
222
+ }
223
+
224
+ return result;
225
+ }
226
+
227
+ /** Settings addressed by a selector: `set ftp ...`, `set [ find default=yes ] ...`. They are changed, never added or removed. */
228
+ private settings(menu: string, wanted: Command[], existing: Command[]): Step | undefined {
229
+ const step: Step = { title: menu, show: [], send: [] };
230
+ const wantedBySelector = new Map(wanted.filter((item) => item.verb === "set").map((item) => [item.selector, item]));
231
+ const existingBySelector = new Map(existing.filter((item) => item.verb === "set").map((item) => [item.selector, item]));
232
+
233
+ for (const selector of new Set([...wantedBySelector.keys(), ...existingBySelector.keys()])) {
234
+ const want = wantedBySelector.get(selector);
235
+ const have = existingBySelector.get(selector);
236
+ // A setting only the device states is reset by comparing it with nothing.
237
+ const target = want ?? { ...have!, attrs: {} };
238
+ const update = this.changes(target, have, `${menu} ${selector}`);
239
+ if (!Object.keys(update).length) continue;
240
+
241
+ step.show.push(`~ set ${selector} ${RouterOsPlan.describe(update)}`.replace(/\s+/g, " "));
242
+ step.send.push(RouterOsPlan.line(menu, "set", selector, [], update));
243
+ }
244
+
245
+ return step.send.length ? step : undefined;
246
+ }
247
+
248
+ /** Entries, told apart by identity. New ones are added, changed ones set, and ones the render does not hold removed in the last phase. */
249
+ private entries(menu: string, wanted: Command[], existing: Command[]): MenuPlan {
250
+ const result: MenuPlan = { first: [], last: [] };
251
+ const changeStep: Step = { title: menu, show: [], send: [] };
252
+ const removeStep: Step = { title: menu, show: [], send: [] };
253
+ const key = (c: Command) => JSON.stringify(RouterOsExport.identity(c));
254
+
255
+ const wantedEntries = wanted.filter((item) => item.verb === "add");
256
+ const existingEntries = existing.filter((item) => item.verb === "add");
257
+
258
+ const existingByKey = new Map<string, Command>();
259
+
260
+ for (const entry of existingEntries) {
261
+ const entryKey = key(entry);
262
+ if (existingByKey.has(entryKey))
263
+ this.problems.push(`${menu}: two entries share ${entryKey}, so neither can be addressed alone`);
264
+ existingByKey.set(entryKey, entry);
265
+ }
266
+
267
+ const kept = new Set<string>();
268
+
269
+ // An entry known by something other than its name keeps its name when that something changes, and the device refuses a second entry of that name. The old entry of the same name is changed in place, or removed before the new one is added.
270
+ const wantedKeys = new Set(wantedEntries.map(key));
271
+ const sameName = new Map(
272
+ existingEntries
273
+ .filter((entry) => entry.attrs.name !== undefined && !wantedKeys.has(key(entry)))
274
+ .map((entry) => [entry.attrs.name!, entry])
275
+ );
276
+
277
+ for (const entry of wantedEntries) {
278
+ const entryKey = key(entry);
279
+ kept.add(entryKey);
280
+ let current = existingByKey.get(entryKey);
281
+
282
+ const namesake = !current && entry.attrs.name !== undefined ? sameName.get(entry.attrs.name) : undefined;
283
+ if (namesake) {
284
+ kept.add(key(namesake));
285
+
286
+ if (RouterOsPlan.CHANGED_IN_PLACE.has(menu)) {
287
+ current = namesake;
288
+ } else {
289
+ changeStep.show.push(`~ replace ${RouterOsExport.canonical(entry)}`);
290
+ changeStep.send.push(`${menu} remove ${RouterOsPlan.find(menu, RouterOsExport.identity(namesake))}`);
291
+ changeStep.send.push(RouterOsPlan.line(menu, "add", "", entry.flags, entry.attrs));
292
+ continue;
293
+ }
294
+ }
295
+
296
+ if (!current) {
297
+ changeStep.show.push(`+ ${RouterOsExport.canonical(entry)}`);
298
+ changeStep.send.push(RouterOsPlan.line(menu, "add", "", entry.flags, entry.attrs));
299
+ continue;
300
+ }
301
+
302
+ // A flag such as `blackhole` cannot be changed on an existing entry. It is replaced.
303
+ const flagsDiffer = [...entry.flags].sort().join() !== [...current.flags].sort().join();
304
+ if (flagsDiffer) {
305
+ changeStep.show.push(`~ replace ${RouterOsExport.canonical(entry)}`);
306
+ removeStep.send.push(`${menu} remove ${RouterOsPlan.find(menu, RouterOsExport.identity(current))}`);
307
+ changeStep.send.push(RouterOsPlan.line(menu, "add", "", entry.flags, entry.attrs));
308
+ continue;
309
+ }
310
+
311
+ const update = this.changes(entry, current, `${menu} ${entryKey}`);
312
+ if (!Object.keys(update).length) continue;
313
+
314
+ changeStep.show.push(`~ ${entryKey} ${RouterOsPlan.describe(update)}`);
315
+ changeStep.send.push(RouterOsPlan.line(menu, "set", RouterOsPlan.find(menu, RouterOsExport.identity(current)), [], update));
316
+ }
317
+
318
+ const removed = existingEntries.filter((entry) => !kept.has(key(entry)));
319
+ // An address list that goes entirely goes in one command, not one per entry.
320
+ const wholeLists = new Set<string>();
321
+ if (menu.endsWith("address-list")) {
322
+ for (const entry of removed) {
323
+ const list = entry.attrs.list!;
324
+ if (!wantedEntries.some((candidate) => candidate.attrs.list === list)) wholeLists.add(list);
325
+ }
326
+ }
327
+
328
+ for (const list of wholeLists) {
329
+ const count = removed.filter((entry) => entry.attrs.list === list).length;
330
+ removeStep.show.push(`- list ${list}, ${count} entr${count === 1 ? "y" : "ies"}`);
331
+ removeStep.send.push(`${menu} remove [ find list=${RouterOsPlan.quote(list)} and !dynamic ]`);
332
+ }
333
+
334
+ for (const entry of removed) {
335
+ if (wholeLists.has(entry.attrs.list ?? "")) continue;
336
+
337
+ removeStep.show.push(`- ${RouterOsExport.canonical(entry)}`);
338
+ removeStep.send.push(`${menu} remove ${RouterOsPlan.find(menu, RouterOsExport.identity(entry))}`);
339
+ }
340
+
341
+ if (changeStep.send.length) result.first.push(changeStep);
342
+ if (removeStep.send.length) result.last.push(removeStep);
343
+ return result;
344
+ }
345
+
346
+ /**
347
+ * Certificates are installed from the config when missing or different, after the old one of that name is removed. One the device holds under another name, with the same fingerprint and key, is renamed rather than imported a second time. One the config does not hold is removed in the last phase.
348
+ */
349
+ private static certificates(wanted: Command[], existing: Command[]): MenuPlan {
350
+ const menu = "/certificate";
351
+ const result: MenuPlan = { first: [], last: [] };
352
+ const install: Step = { title: menu, show: [], send: [] };
353
+ const remove: Step = { title: menu, show: [], send: [] };
354
+ const existingByName = new Map(existing.map((item) => [item.attrs.name, item]));
355
+ const wantedNames = new Set(wanted.map((item) => item.attrs.name));
356
+ const renamed = new Set<string>();
357
+
358
+ for (const entry of wanted) {
359
+ const name = entry.attrs.name!;
360
+ const current = existingByName.get(name);
361
+ if (current && RouterOsExport.canonical(current) === RouterOsExport.canonical(entry)) continue;
362
+
363
+ const sameCertificate = existing.find(
364
+ (item) =>
365
+ !wantedNames.has(item.attrs.name) &&
366
+ !renamed.has(item.attrs.name!) &&
367
+ item.attrs.fingerprint === entry.attrs.fingerprint &&
368
+ item.attrs["private-key"] === entry.attrs["private-key"]
369
+ );
370
+
371
+ if (!current && sameCertificate) {
372
+ const oldName = sameCertificate.attrs.name!;
373
+ renamed.add(oldName);
374
+ install.show.push(`~ rename ${oldName} to ${name}`);
375
+ install.send.push(`${menu} set [ find name=${RouterOsPlan.quote(oldName)} ] name=${RouterOsPlan.quote(name)}`);
376
+ continue;
377
+ }
378
+
379
+ install.show.push(`${current ? "~" : "+"} install ${name}`);
380
+ if (current) install.send.push(`${menu} remove [ find name=${RouterOsPlan.quote(name)} ]`);
381
+ install.send.push(RouterOsPlan.INSTALL_CERTIFICATE + name);
382
+ }
383
+
384
+ for (const entry of existing) {
385
+ if (wantedNames.has(entry.attrs.name) || renamed.has(entry.attrs.name!)) continue;
386
+
387
+ remove.show.push(`- ${entry.attrs.name}`);
388
+ remove.send.push(`${menu} remove [ find name=${RouterOsPlan.quote(entry.attrs.name!)} ]`);
389
+ }
390
+
391
+ if (install.send.length) result.first.push(install);
392
+ if (remove.send.length) result.last.push(remove);
393
+ return result;
394
+ }
395
+
396
+ /**
397
+ * An ordered menu is rewritten whole whenever it differs, so the device lists its rules in the order the render gives.
398
+ *
399
+ * The old rules are captured, the new ones added after them, then the captured ones removed. Until that moment the old rules keep deciding, so no chain is ever empty or half new.
400
+ *
401
+ * In a menu with chains, an old rule is removed in this step only if its chain is still wanted. A chain no longer wanted is removed in the last phase, after whatever pointed at it has been changed.
402
+ */
403
+ private static ordered(menu: string, wanted: Command[], existing: Command[]): MenuPlan {
404
+ const result: MenuPlan = { first: [], last: [] };
405
+ const same =
406
+ wanted.map((item) => RouterOsExport.canonical(item)).join("\n") ===
407
+ existing.map((item) => RouterOsExport.canonical(item)).join("\n");
408
+ if (same) return result;
409
+
410
+ const oldRules = RouterOsPlan.OLD_RULES;
411
+ const where = RouterOsPlan.DYNAMIC.has(menu) ? " where !dynamic" : "";
412
+ const hasChains = [...wanted, ...existing].some((item) => item.attrs.chain !== undefined);
413
+ const keptChains = new Set(wanted.map((item) => item.attrs.chain ?? ""));
414
+ const droppedChains = [...new Set(existing.map((item) => item.attrs.chain ?? ""))].filter((chain) => !keptChains.has(chain));
415
+
416
+ let removeOld = `${menu} remove $${oldRules}`;
417
+ if (hasChains) {
418
+ const keep = [...keptChains].map((chain) => RouterOsPlan.quote(chain)).join(";");
419
+ const inKeptChain = `[:typeof [:find $keep [${menu} get $id chain]]] != "nil"`;
420
+ removeOld = `:local keep {${keep}}; :foreach id in=$${oldRules} do={ :if (${inKeptChain}) do={ ${menu} remove $id } }`;
421
+ }
422
+
423
+ const send = [
424
+ // A global survives between the commands of one run, so the rules captured here are the ones removed below.
425
+ `:global ${oldRules} [${menu} find${where}]`,
426
+ ...wanted.map((item) => RouterOsPlan.line(menu, "add", "", item.flags, item.attrs)),
427
+ `:global ${oldRules}; ${removeOld}; /system script environment remove [find name="${oldRules}"]`,
428
+ ];
429
+ const show = [
430
+ ...existing.map((item) => `- ${RouterOsExport.canonical(item)}`),
431
+ ...wanted.map((item) => `+ ${RouterOsExport.canonical(item)}`),
432
+ ];
433
+ result.first.push({ title: `${menu}, in the order of the config`, show, send });
434
+
435
+ if (hasChains) {
436
+ for (const chain of droppedChains) {
437
+ const selector = `[ find chain=${RouterOsPlan.quote(chain)}${RouterOsPlan.DYNAMIC.has(menu) ? " and !dynamic" : ""} ]`;
438
+ result.last.push({ title: `${menu} chain ${chain}`, show: [`- chain ${chain}`], send: [`${menu} remove ${selector}`] });
439
+ }
440
+ }
441
+
442
+ return result;
443
+ }
444
+ }