@takodotid/circuit 0.34.3 → 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 +11345 -9024
  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 -26
  211. package/src/adapters/devices/raisecom-ros/index.ts +0 -700
  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,620 @@
1
+ // Keeps NetBox in step with the config, one way: Circuit writes, NetBox shows. Each device goes in with its site, role, type and platform, every port of its model, its interfaces, VLANs, VRFs and addresses, and a cable for each `link`.
2
+ //
3
+ // Circuit marks what it makes with the tag `circuit`, and changes or deletes only objects with that tag. An object NetBox already holds without it, such as a site the team made, is used as it is and never changed. BGP sessions are left out: NetBox has no place for them without a plugin.
4
+
5
+ import { Address } from "../core/address";
6
+ import { AddressSpace } from "../core/address-space";
7
+ import type { NetBoxDevice, Network } from "../core/define";
8
+ import { Secrets } from "../core/secrets";
9
+ import { Topology, type Holder } from "../core/topology";
10
+ import { Platforms } from "../platforms";
11
+ import { Catalog, type PlatformName } from "../platforms/catalog";
12
+ import type { Device, Interface, Port, Switched } from "../schema";
13
+
14
+ /** NetBox's JSON, as it answers. Loose, since every kind of object has fields of its own. */
15
+ type Json = { [field: string]: any };
16
+
17
+ /** Each kind of object Circuit writes, in the order they are made: each refers only to kinds before it, or waits for the next pass. */
18
+ export type NetBoxKind =
19
+ | "tag"
20
+ | "site"
21
+ | "manufacturer"
22
+ | "platform"
23
+ | "device-type"
24
+ | "role"
25
+ | "vrf"
26
+ | "vlan"
27
+ | "device"
28
+ | "interface"
29
+ | "prefix"
30
+ | "ip-range"
31
+ | "ip"
32
+ | "cable";
33
+
34
+ /** One object to make, change or delete. */
35
+ export type NetBoxChange = {
36
+ kind: NetBoxKind;
37
+ /** Where NetBox keeps it, such as `dcim/devices`. */
38
+ path: string;
39
+ /** How Circuit knows it, built from its own fields, such as `edge-01/sfp28-1` for an interface. */
40
+ key: string;
41
+ /** What is sent. References to other objects are filled in with their ids when sent. */
42
+ fields: Record<string, unknown>;
43
+ /** NetBox's id, for an object that exists. */
44
+ id?: number;
45
+ /** For a change, each field that differs, as `field: before to after`. */
46
+ diff: string[];
47
+ };
48
+
49
+ /** What `circuit netbox` would do. */
50
+ export type NetBoxPlan = {
51
+ creates: NetBoxChange[];
52
+ updates: NetBoxChange[];
53
+ deletes: NetBoxChange[];
54
+ /** Objects NetBox holds without the tag that Circuit would otherwise make or change. Circuit refers to them and leaves them as they are. */
55
+ leftAlone: string[];
56
+ };
57
+
58
+ /** Another object, by kind and key, sent as its id once NetBox has it. */
59
+ class NetBoxRef {
60
+ constructor(
61
+ readonly kind: NetBoxKind,
62
+ readonly key: string
63
+ ) {}
64
+ }
65
+
66
+ export class NetBox {
67
+ /** The tag on everything Circuit makes. */
68
+ static readonly TAG = "circuit";
69
+
70
+ /** How many rounds `send` takes at most: a field that refers to an object made in the same round, such as a device's primary address, is set in the next. */
71
+ private static readonly PASSES = 3;
72
+
73
+ /** Where each kind is kept, and how an object NetBox answers with is known by its key. */
74
+ private static readonly KINDS: Record<NetBoxKind, { path: string; key: (object: Json) => string }> = {
75
+ tag: { path: "extras/tags", key: (o) => o.slug },
76
+ site: { path: "dcim/sites", key: (o) => o.name },
77
+ manufacturer: { path: "dcim/manufacturers", key: (o) => o.slug },
78
+ platform: { path: "dcim/platforms", key: (o) => o.slug },
79
+ "device-type": { path: "dcim/device-types", key: (o) => `${o.manufacturer?.slug}/${o.slug}` },
80
+ role: { path: "dcim/device-roles", key: (o) => o.name },
81
+ vrf: { path: "ipam/vrfs", key: (o) => o.name },
82
+ vlan: { path: "ipam/vlans", key: (o) => `${o.site?.name}/${o.vid}` },
83
+ device: { path: "dcim/devices", key: (o) => o.name },
84
+ interface: { path: "dcim/interfaces", key: (o) => `${o.device?.name}/${o.name}` },
85
+ prefix: { path: "ipam/prefixes", key: (o) => NetBox.prefixKey(o.prefix, o.vrf?.name) },
86
+ "ip-range": {
87
+ path: "ipam/ip-ranges",
88
+ key: (o) => `${Address.canonical(o.start_address)}-${Address.canonical(o.end_address)}`,
89
+ },
90
+ ip: { path: "ipam/ip-addresses", key: (o) => `${Address.canonical(o.address)}${NetBox.assignedTo(o)}` },
91
+ cable: {
92
+ path: "dcim/cables",
93
+ key: (o) => [...o.a_terminations, ...o.b_terminations].map(NetBox.endOf).sort().join(" to "),
94
+ },
95
+ };
96
+
97
+ private static readonly ORDER = Object.keys(NetBox.KINDS) as NetBoxKind[];
98
+
99
+ /** Fields sent when an object is made and never compared after: its tags, which people may add to, and a cable's ends, which its key already holds. */
100
+ private static readonly CREATE_ONLY = new Set(["tags", "a_terminations", "b_terminations"]);
101
+
102
+ /** Fields that hold an address, compared in one spelling. */
103
+ private static readonly ADDRESS_FIELDS = new Set(["address", "prefix", "start_address", "end_address"]);
104
+
105
+ /** Who makes the hardware each platform runs on: the config names the platform, not the vendor. */
106
+ private static readonly VENDORS: Record<PlatformName, string> = {
107
+ routeros: "MikroTik",
108
+ vrp: "Huawei",
109
+ "raisecom-ros": "Raisecom",
110
+ junos: "Juniper",
111
+ fortios: "Fortinet",
112
+ };
113
+
114
+ /** NetBox's type for a port, by the speed class in Circuit's port name. */
115
+ private static readonly PORT_TYPES: Record<string, string> = {
116
+ "100g": "100gbase-x-qsfp28",
117
+ "50g": "50gbase-x-sfp56",
118
+ "40g": "40gbase-x-qsfpp",
119
+ "25g": "25gbase-x-sfp28",
120
+ "10g": "10gbase-x-sfpp",
121
+ "1g": "1000base-t",
122
+ "100m": "100base-tx",
123
+ };
124
+
125
+ /** NetBox's type for every interface that is not a port. */
126
+ private static readonly INTERFACE_TYPES: Record<Interface<string, string>["type"], string> = {
127
+ lag: "lag",
128
+ bridge: "bridge",
129
+ vlan: "virtual",
130
+ loopback: "virtual",
131
+ gre: "virtual",
132
+ vxlan: "virtual",
133
+ wireguard: "virtual",
134
+ };
135
+
136
+ /** A port's fixed speed in kbit/s, as NetBox counts it. */
137
+ private static readonly SPEEDS: Record<NonNullable<Port<string, string, string, string>["speed"]>, number> = {
138
+ "100m": 100_000,
139
+ "1g": 1_000_000,
140
+ "10g": 10_000_000,
141
+ "25g": 25_000_000,
142
+ "40g": 40_000_000,
143
+ "50g": 50_000_000,
144
+ "100g": 100_000_000,
145
+ };
146
+
147
+ private readonly base: string;
148
+ private readonly headers: Record<string, string>;
149
+ /** Every object the config asks for, by kind and key. */
150
+ private readonly wanted = new Map<NetBoxKind, Map<string, Record<string, unknown>>>(NetBox.ORDER.map((kind) => [kind, new Map()]));
151
+ /** NetBox's id for each object it holds, by kind and key. */
152
+ private readonly ids = new Map<string, number>();
153
+
154
+ constructor(private readonly network: Network) {
155
+ const settings = network.netbox;
156
+ if (!settings) throw new Error("the network has no netbox settings");
157
+
158
+ const siteOf = network.site;
159
+ if (!siteOf) throw new Error("the network has no site: NetBox needs one for every device");
160
+
161
+ this.base = `${settings.url.replace(/\/+$/, "")}/api`;
162
+
163
+ // A version 2 token, NetBox 4.5 and later, starts with `nbt_` and goes after `Bearer`; an older one after `Token`.
164
+ const token = Secrets.reveal(settings.api_token);
165
+ this.headers = {
166
+ Authorization: `${token.startsWith("nbt_") ? "Bearer" : "Token"} ${token}`,
167
+ "Content-Type": "application/json",
168
+ Accept: "application/json",
169
+ };
170
+
171
+ this.want("tag", NetBox.TAG, { name: NetBox.TAG, slug: NetBox.TAG, description: "Made by Circuit, which changes and deletes it" });
172
+
173
+ const holders = Topology.holders(network.devices);
174
+ for (const device of network.devices) this.wantDevice(device, siteOf(device), settings.device(device), holders);
175
+
176
+ this.wantPrefixes(holders, siteOf);
177
+ this.wantUnassigned(holders);
178
+ }
179
+
180
+ /** Compare the config with what NetBox holds. Reads NetBox only. */
181
+ async plan(): Promise<NetBoxPlan> {
182
+ const plan: NetBoxPlan = { creates: [], updates: [], deletes: [], leftAlone: [] };
183
+ const existing = new Map<NetBoxKind, Map<string, Json>>();
184
+
185
+ // Every id first, so a field can refer to any kind.
186
+ this.ids.clear();
187
+
188
+ for (const kind of NetBox.ORDER) {
189
+ const objects = new Map((await this.list(NetBox.KINDS[kind].path)).map((object) => [NetBox.KINDS[kind].key(object), object]));
190
+ for (const [key, object] of objects) this.ids.set(NetBox.idKey(kind, key), object.id);
191
+
192
+ existing.set(kind, objects);
193
+ }
194
+
195
+ for (const kind of NetBox.ORDER) {
196
+ const path = NetBox.KINDS[kind].path;
197
+ const objects = existing.get(kind)!;
198
+ const wanted = this.wanted.get(kind)!;
199
+
200
+ for (const [key, fields] of wanted) {
201
+ const found = objects.get(key);
202
+
203
+ if (!found) {
204
+ const tags = kind === "tag" ? {} : { tags: [new NetBoxRef("tag", NetBox.TAG)] };
205
+ plan.creates.push({ kind, path, key, fields: { ...fields, ...tags }, diff: [] });
206
+ continue;
207
+ }
208
+
209
+ if (kind !== "tag" && !NetBox.isTagged(found)) {
210
+ plan.leftAlone.push(`${path} ${key}`);
211
+ continue;
212
+ }
213
+
214
+ const diff = Object.entries(fields).flatMap(([field, value]) => {
215
+ if (NetBox.CREATE_ONLY.has(field)) return [];
216
+
217
+ const ours = JSON.stringify(NetBox.normalize(this.resolve(value), field));
218
+ const theirs = JSON.stringify(NetBox.normalize(found[field], field));
219
+ return ours === theirs ? [] : [`${field}: ${theirs} to ${ours}`];
220
+ });
221
+
222
+ if (diff.length) plan.updates.push({ kind, path, key, fields, id: found.id, diff });
223
+ }
224
+
225
+ if (kind === "tag") continue;
226
+
227
+ for (const [key, object] of objects) {
228
+ if (!wanted.has(key) && NetBox.isTagged(object))
229
+ plan.deletes.push({ kind, path, key, fields: {}, id: object.id, diff: [] });
230
+ }
231
+ }
232
+
233
+ return plan;
234
+ }
235
+
236
+ /** Send the plan, then plan again and send what is left, until NetBox matches or `PASSES` rounds are done. Returns what is left. */
237
+ async send(plan: NetBoxPlan): Promise<NetBoxPlan> {
238
+ let left = plan;
239
+
240
+ for (let pass = 1; pass <= NetBox.PASSES && NetBox.count(left); pass++) {
241
+ await this.apply(left);
242
+ console.log(` pass ${pass}: ${NetBox.count(left)} sent`);
243
+
244
+ left = await this.plan();
245
+ }
246
+
247
+ return left;
248
+ }
249
+
250
+ /** How many changes a plan holds. */
251
+ static count(plan: NetBoxPlan): number {
252
+ return plan.creates.length + plan.updates.length + plan.deletes.length;
253
+ }
254
+
255
+ private async apply(plan: NetBoxPlan): Promise<void> {
256
+ // Deletes go first, the last kind first, so nothing still refers to what goes.
257
+ const deletes = [...plan.deletes].sort((a, b) => NetBox.ORDER.indexOf(b.kind) - NetBox.ORDER.indexOf(a.kind));
258
+ for (const change of deletes) await this.call("DELETE", `${change.path}/${change.id}/`);
259
+
260
+ for (const kind of NetBox.ORDER) {
261
+ for (const change of plan.creates.filter((candidate) => candidate.kind === kind)) {
262
+ const made = (await this.call("POST", `${change.path}/`, this.sendable(change.fields, false)))!;
263
+ this.ids.set(NetBox.idKey(kind, change.key), made.id);
264
+ }
265
+
266
+ for (const change of plan.updates.filter((candidate) => candidate.kind === kind)) {
267
+ await this.call("PATCH", `${change.path}/${change.id}/`, this.sendable(change.fields, true));
268
+ }
269
+ }
270
+ }
271
+
272
+ /** Declare an object. The first declaration of a key wins; a later one only fills fields still empty. */
273
+ private want(kind: NetBoxKind, key: string, fields: Record<string, unknown>): void {
274
+ const known = this.wanted.get(kind)!.get(key);
275
+
276
+ if (!known) {
277
+ this.wanted.get(kind)!.set(key, fields);
278
+ return;
279
+ }
280
+
281
+ for (const [field, value] of Object.entries(fields)) {
282
+ if (known[field] === undefined || known[field] === null || known[field] === "") known[field] = value;
283
+ }
284
+ }
285
+
286
+ /** A device, its place, VLANs, VRFs, ports, interfaces, addresses and cables. */
287
+ private wantDevice(device: Device, site: string, about: NetBoxDevice, holders: readonly Holder[]): void {
288
+ const ref = (kind: NetBoxKind, key: string) => new NetBoxRef(kind, key);
289
+ const vendor = NetBox.VENDORS[device.platform as PlatformName];
290
+ const manufacturer = NetBox.slug(vendor);
291
+ const model = about.device_type ?? device.model;
292
+ const deviceType = `${manufacturer}/${NetBox.slug(model)}`;
293
+
294
+ this.want("site", site, { name: site, slug: NetBox.slug(site), status: "active" });
295
+ this.want("manufacturer", manufacturer, { name: vendor, slug: manufacturer });
296
+ this.want("platform", device.platform, {
297
+ name: Platforms.get(device.platform).os.name,
298
+ slug: device.platform,
299
+ manufacturer: ref("manufacturer", manufacturer),
300
+ });
301
+ this.want("device-type", deviceType, { manufacturer: ref("manufacturer", manufacturer), model, slug: NetBox.slug(model) });
302
+ this.want("role", about.role, { name: about.role, slug: NetBox.slug(about.role) });
303
+
304
+ for (const [name, vrf] of Object.entries(device.vrfs ?? {})) this.want("vrf", name, { name, description: vrf.description ?? "" });
305
+
306
+ for (const [name, vlan] of Object.entries(device.vlans ?? {})) {
307
+ this.want("vlan", `${site}/${vlan.id}`, {
308
+ site: ref("site", site),
309
+ vid: vlan.id,
310
+ name,
311
+ description: vlan.description ?? "",
312
+ status: "active",
313
+ });
314
+ }
315
+
316
+ const named = (port: string) => NetBox.interfaceName(device, port);
317
+ const interfaceRef = (name: string) => ref("interface", `${device.name}/${named(name)}`);
318
+ const vlanRef = (name: string) => ref("vlan", `${site}/${device.vlans?.[name]?.id}`);
319
+
320
+ // How a port, LAG or VLAN interface is switched, in NetBox's terms.
321
+ const switching = (settings: Switched<string> & { vlan?: string }) => {
322
+ const access = settings.access_vlan ?? settings.vlan;
323
+ if (access) return { mode: "access", untagged_vlan: vlanRef(access), tagged_vlans: [] };
324
+
325
+ if (settings.trunk_vlans) {
326
+ return {
327
+ mode: "tagged",
328
+ untagged_vlan: settings.native_vlan ? vlanRef(settings.native_vlan) : null,
329
+ tagged_vlans: settings.trunk_vlans.filter((name) => name !== settings.native_vlan).map(vlanRef),
330
+ };
331
+ }
332
+
333
+ return { mode: null, untagged_vlan: null, tagged_vlans: [] };
334
+ };
335
+
336
+ const bridgeOf = new Map(
337
+ Object.entries(device.interfaces ?? {}).flatMap(([name, iface]) =>
338
+ iface.type === "bridge" ? iface.members.map((member) => [member, name] as const) : []
339
+ )
340
+ );
341
+
342
+ // LAGs first, so a port made in the same round can name its LAG.
343
+ const interfaces = Object.entries(device.interfaces ?? {}).sort(
344
+ ([, a], [, b]) => Number(b.type === "lag") - Number(a.type === "lag")
345
+ );
346
+
347
+ for (const [name, iface] of interfaces) {
348
+ const bridge = bridgeOf.get(name);
349
+
350
+ this.want("interface", `${device.name}/${name}`, {
351
+ device: ref("device", device.name),
352
+ name,
353
+ label: "",
354
+ type: NetBox.INTERFACE_TYPES[iface.type],
355
+ enabled: true,
356
+ description: iface.description ?? "",
357
+ mtu: iface.mtu ?? null,
358
+ speed: null,
359
+ lag: null,
360
+ bridge: bridge ? interfaceRef(bridge) : null,
361
+ vrf: iface.vrf ? ref("vrf", iface.vrf) : null,
362
+ ...switching(iface as Switched<string> & { vlan?: string }),
363
+ });
364
+ }
365
+
366
+ // Every port of the model. One the config does not declare is shut, as on the device.
367
+ for (const portName of Catalog.ports(device.platform, device.model)) {
368
+ const port = device.ports?.[portName];
369
+
370
+ this.want("interface", `${device.name}/${named(portName)}`, {
371
+ device: ref("device", device.name),
372
+ name: named(portName),
373
+ label: portName,
374
+ type: NetBox.PORT_TYPES[portName.split("-")[0]!] ?? "other",
375
+ enabled: Boolean(port),
376
+ description: port?.description ?? "",
377
+ mtu: port?.mtu ?? null,
378
+ speed: port?.speed ? NetBox.SPEEDS[port.speed] : null,
379
+ lag: port?.lag ? interfaceRef(port.lag) : null,
380
+ bridge: null,
381
+ vrf: port?.vrf ? ref("vrf", port.vrf) : null,
382
+ ...(port && !port.lag ? switching(port) : switching({})),
383
+ });
384
+ }
385
+
386
+ // The addresses on them. The one Circuit reaches the device at is its primary address.
387
+ let primary: { address: string; key: string } | undefined;
388
+
389
+ for (const holder of holders.filter((candidate) => candidate.device === device)) {
390
+ const address = Address.canonical(holder.address);
391
+ const key = `${address} on ${device.name}/${named(holder.name)}`;
392
+ const loopback = device.interfaces?.[holder.name]?.type === "loopback";
393
+
394
+ this.want("ip", key, {
395
+ address,
396
+ status: "active",
397
+ role: loopback ? "loopback" : null,
398
+ vrf: holder.vrf ? ref("vrf", holder.vrf) : null,
399
+ description: "",
400
+ assigned_object_type: "dcim.interface",
401
+ assigned_object_id: interfaceRef(holder.name),
402
+ });
403
+
404
+ const host = device.connection.host;
405
+ if (!primary && host && Address.canonical(Address.host(holder.address)) === Address.canonical(host)) primary = { address, key };
406
+ }
407
+
408
+ const primaryOf = (family: "ipv4" | "ipv6") =>
409
+ primary && Address.family(primary.address) === family ? ref("ip", primary.key) : null;
410
+
411
+ this.want("device", device.name, {
412
+ name: device.name,
413
+ device_type: ref("device-type", deviceType),
414
+ role: ref("role", about.role),
415
+ platform: ref("platform", device.platform),
416
+ site: ref("site", site),
417
+ status: "active",
418
+ primary_ip4: primaryOf("ipv4"),
419
+ primary_ip6: primaryOf("ipv6"),
420
+ });
421
+
422
+ // A cable for each link. Both ends may declare it, so it is known by its two ends in order.
423
+ for (const [portName, port] of Object.entries(device.ports ?? {})) {
424
+ if (!port?.link) continue;
425
+
426
+ const far = this.network.devices.find((candidate) => candidate.name === port.link!.device);
427
+ if (!far) continue;
428
+
429
+ const [a, b] = [`${device.name}/${named(portName)}`, `${far.name}/${NetBox.interfaceName(far, port.link.port)}`].sort() as [
430
+ string,
431
+ string,
432
+ ];
433
+ const end = (key: string) => [{ object_type: "dcim.interface", object_id: ref("interface", key) }];
434
+
435
+ this.want("cable", `${a} to ${b}`, { a_terminations: end(a), b_terminations: end(b), status: "connected" });
436
+ }
437
+ }
438
+
439
+ /** A prefix for each subnet an interface is on, and a container for each prefix the network originates in BGP. */
440
+ private wantPrefixes(holders: readonly Holder[], siteOf: (device: Device) => string): void {
441
+ for (const holder of holders) {
442
+ const { len, family } = Address.split(holder.address);
443
+ if (holder.peer || len === Address.bits(family)) continue;
444
+
445
+ const prefix = Address.canonical(Address.prefixOf(holder.address));
446
+ const site = siteOf(holder.device);
447
+ const iface = holder.device.interfaces?.[holder.name];
448
+ const settings = iface ?? holder.device.ports?.[holder.name];
449
+ const vlanId = iface?.type === "vlan" ? holder.device.vlans?.[iface.vlan]?.id : undefined;
450
+
451
+ this.want("prefix", NetBox.prefixKey(prefix, holder.vrf), {
452
+ prefix,
453
+ status: "active",
454
+ vrf: holder.vrf ? new NetBoxRef("vrf", holder.vrf) : null,
455
+ description: settings?.description ?? "",
456
+ vlan: vlanId === undefined ? null : new NetBoxRef("vlan", `${site}/${vlanId}`),
457
+ scope_type: "dcim.site",
458
+ scope_id: new NetBoxRef("site", site),
459
+ });
460
+ }
461
+
462
+ for (const device of this.network.devices) {
463
+ for (const network of device.routing?.bgp?.networks ?? []) {
464
+ if (!Address.isCidr(network)) continue;
465
+
466
+ const prefix = Address.canonical(Address.prefixOf(network));
467
+ this.want("prefix", NetBox.prefixKey(prefix, undefined), {
468
+ prefix,
469
+ status: "container",
470
+ vrf: null,
471
+ description: "",
472
+ vlan: null,
473
+ scope_type: null,
474
+ scope_id: null,
475
+ });
476
+ }
477
+ }
478
+ }
479
+
480
+ /** Addresses Circuit knows that sit on no interface of ours, as `circuit addresses` lists them: peers, next hops, reservations, VRRP, and DHCP pools as ranges. */
481
+ private wantUnassigned(holders: readonly Holder[]): void {
482
+ const held = new Set(holders.map((holder) => Address.canonical(Address.host(holder.address))));
483
+ const report = AddressSpace.report(this.network.devices);
484
+ const segments = [...report.spaces.flatMap((space) => space.segments), ...report.elsewhere.segments];
485
+
486
+ for (const segment of segments) {
487
+ const length = Address.split(segment.prefix).len;
488
+
489
+ for (const use of segment.uses) {
490
+ const [first, last] = use.address.split("-") as [string, string | undefined];
491
+ const common = { status: "active", description: use.what };
492
+
493
+ if (last) {
494
+ const start = Address.canonical(`${first}/${length}`);
495
+ const end = Address.canonical(`${last}/${length}`);
496
+ this.want("ip-range", `${start}-${end}`, { start_address: start, end_address: end, ...common });
497
+ continue;
498
+ }
499
+
500
+ if (held.has(Address.canonical(first))) continue;
501
+
502
+ const address = Address.canonical(`${first}/${length}`);
503
+ this.want("ip", address, { address, ...common });
504
+ }
505
+ }
506
+ }
507
+
508
+ /** A value with every reference replaced by its id, or by `<kind key>` while the object does not exist. */
509
+ private resolve(value: unknown): unknown {
510
+ if (value instanceof NetBoxRef) return this.ids.get(NetBox.idKey(value.kind, value.key)) ?? `<${value.kind} ${value.key}>`;
511
+ if (Array.isArray(value)) return value.map((item) => this.resolve(item));
512
+ if (value && typeof value === "object")
513
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, this.resolve(item)]));
514
+
515
+ return value;
516
+ }
517
+
518
+ /** The fields NetBox can take now. A reference to an object not made yet waits for the next round. */
519
+ private sendable(fields: Record<string, unknown>, update: boolean): Record<string, unknown> {
520
+ return Object.fromEntries(
521
+ Object.entries(fields).flatMap(([field, value]) => {
522
+ if (update && NetBox.CREATE_ONLY.has(field)) return [];
523
+
524
+ const resolved = this.resolve(value);
525
+ return JSON.stringify(resolved).includes('"<') ? [] : [[field, resolved]];
526
+ })
527
+ );
528
+ }
529
+
530
+ private async call(method: "GET" | "POST" | "PATCH" | "DELETE", path: string, body?: unknown): Promise<Json | undefined> {
531
+ const response = await fetch(`${this.base}/${path}`, {
532
+ method,
533
+ headers: this.headers,
534
+ body: body === undefined ? undefined : JSON.stringify(body),
535
+ signal: AbortSignal.timeout(60_000),
536
+ });
537
+
538
+ // An object deleted with its parent, such as a device's interfaces, is already gone.
539
+ if (response.status === 404 && method === "DELETE") return undefined;
540
+
541
+ if (!response.ok) {
542
+ const reason = (await response.text()).slice(0, 500);
543
+ throw new Error(`NetBox answered ${response.status} to ${method} ${path}: ${reason}`);
544
+ }
545
+
546
+ return response.status === 204 ? undefined : ((await response.json()) as Json);
547
+ }
548
+
549
+ /** Every object of a kind, page by page. */
550
+ private async list(path: string): Promise<Json[]> {
551
+ const all: Json[] = [];
552
+
553
+ for (let next: string | null = `${path}/?limit=1000`; next;) {
554
+ const page = (await this.call("GET", next)) as { results: Json[]; next: string | null };
555
+ all.push(...page.results);
556
+
557
+ // The next page's link is absolute and may name another host behind a proxy, so only its path is kept.
558
+ next = page.next ? page.next.slice(page.next.indexOf("/api/") + "/api/".length) : null;
559
+ }
560
+
561
+ return all;
562
+ }
563
+
564
+ /** NetBox's answer in the shape Circuit sends: a nested object as its id, a choice as its value, a cable end as its type and id, an address in one spelling. */
565
+ private static normalize(value: unknown, field = ""): unknown {
566
+ if (value === undefined || value === null || value === "") return null;
567
+
568
+ if (Array.isArray(value)) {
569
+ return value.map((item) => NetBox.normalize(item)).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
570
+ }
571
+
572
+ if (typeof value === "object") {
573
+ const object = value as Json;
574
+ if ("object_type" in object && "object_id" in object) return { object_type: object.object_type, object_id: object.object_id };
575
+ if ("value" in object && "label" in object) return NetBox.normalize(object.value);
576
+ if ("id" in object) return object.id;
577
+
578
+ return value;
579
+ }
580
+
581
+ if (typeof value === "string" && NetBox.ADDRESS_FIELDS.has(field)) return Address.canonical(value);
582
+ return value;
583
+ }
584
+
585
+ private static isTagged(object: Json): boolean {
586
+ return (object.tags ?? []).some((tag: Json) => tag.slug === NetBox.TAG);
587
+ }
588
+
589
+ /** A port's name on the device, such as `sfp28-1` for `25g-1`, as NetBox shows it. An interface keeps its name from the config. */
590
+ private static interfaceName(device: Device, name: string): string {
591
+ return device.interfaces?.[name] ? name : Catalog.spell(device.platform, device.model, name);
592
+ }
593
+
594
+ private static prefixKey(prefix: string, vrf: string | undefined): string {
595
+ return `${Address.canonical(prefix)}${vrf ? ` in ${vrf}` : ""}`;
596
+ }
597
+
598
+ /** ` on <device>/<interface>` for an address on an interface, or nothing. */
599
+ private static assignedTo(ip: Json): string {
600
+ const holder = ip.assigned_object;
601
+ return holder ? ` on ${holder.device?.name ?? holder.virtual_machine?.name}/${holder.name}` : "";
602
+ }
603
+
604
+ /** One end of a cable, as `<device>/<interface>`, or by its type and id when it is not an interface. */
605
+ private static endOf(end: Json): string {
606
+ return end.object?.device ? `${end.object.device.name}/${end.object.name}` : `${end.object_type} ${end.object_id}`;
607
+ }
608
+
609
+ private static idKey(kind: NetBoxKind, key: string): string {
610
+ return `${kind} ${key}`;
611
+ }
612
+
613
+ /** A name as NetBox's slugs write it: `Core switch` to `core-switch`. */
614
+ private static slug(text: string): string {
615
+ return text
616
+ .toLowerCase()
617
+ .replace(/[^a-z0-9]+/g, "-")
618
+ .replace(/^-|-$/g, "");
619
+ }
620
+ }