@takodotid/circuit 0.4.3 → 0.4.7

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.4.3",
3
+ "version": "0.4.7",
4
4
  "description": "Declarative, vendor-neutral network configuration: describe each device in TypeScript, and Circuit makes the device match. Alpha.",
5
5
  "keywords": [
6
6
  "network",
@@ -42,7 +42,7 @@
42
42
  ".": "./src/index.ts"
43
43
  },
44
44
  "bin": {
45
- "circuit": "./src/cli/index.ts"
45
+ "circuit": "src/cli/index.ts"
46
46
  },
47
47
  "files": [
48
48
  "src",
@@ -10,6 +10,8 @@ export const INSTALL_CERTIFICATE = "#install-certificate ";
10
10
 
11
11
  /** Dependency order. A menu not listed sorts after all of these. The longest matching prefix decides. */
12
12
  const ORDER = [
13
+ // Certificates depend on nothing, and services name them.
14
+ "/certificate",
13
15
  "/interface bridge",
14
16
  "/interface ethernet",
15
17
  "/interface bonding",
@@ -42,7 +44,6 @@ const ORDER = [
42
44
  "/ipv6",
43
45
  "/routing",
44
46
  "/interface ethernet switch",
45
- "/certificate",
46
47
  "/snmp",
47
48
  "/tool",
48
49
  "/user",
@@ -257,7 +258,7 @@ function planEntries(menu: string, wanted: Command[], existing: Command[], sendS
257
258
  }
258
259
 
259
260
  /**
260
- * Certificates are installed from the config when missing or different, after the old one of that name is removed. One the config does not hold is removed in the last phase.
261
+ * 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.
261
262
  */
262
263
  function planCertificates(wanted: Command[], existing: Command[]): MenuPlan {
263
264
  const menu = "/certificate";
@@ -265,19 +266,37 @@ function planCertificates(wanted: Command[], existing: Command[]): MenuPlan {
265
266
  const install: Step = { title: menu, show: [], send: [] };
266
267
  const remove: Step = { title: menu, show: [], send: [] };
267
268
  const existingByName = new Map(existing.map((item) => [item.attrs.name, item]));
269
+ const wantedNames = new Set(wanted.map((item) => item.attrs.name));
270
+ const renamed = new Set<string>();
268
271
 
269
272
  for (const entry of wanted) {
270
273
  const name = entry.attrs.name!;
271
274
  const current = existingByName.get(name);
272
275
  if (current && canonical(current) === canonical(entry)) continue;
273
276
 
277
+ const sameCertificate = existing.find(
278
+ (item) =>
279
+ !wantedNames.has(item.attrs.name) &&
280
+ !renamed.has(item.attrs.name!) &&
281
+ item.attrs.fingerprint === entry.attrs.fingerprint &&
282
+ item.attrs["private-key"] === entry.attrs["private-key"]
283
+ );
284
+
285
+ if (!current && sameCertificate) {
286
+ const oldName = sameCertificate.attrs.name!;
287
+ renamed.add(oldName);
288
+ install.show.push(`~ rename ${oldName} to ${name}`);
289
+ install.send.push(`${menu} set [ find name=${quote(oldName)} ] name=${quote(name)}`);
290
+ continue;
291
+ }
292
+
274
293
  install.show.push(`${current ? "~" : "+"} install ${name}`);
275
294
  if (current) install.send.push(`${menu} remove [ find name=${quote(name)} ]`);
276
295
  install.send.push(INSTALL_CERTIFICATE + name);
277
296
  }
278
297
 
279
298
  for (const entry of existing) {
280
- if (wanted.some((candidate) => candidate.attrs.name === entry.attrs.name)) continue;
299
+ if (wantedNames.has(entry.attrs.name) || renamed.has(entry.attrs.name!)) continue;
281
300
 
282
301
  remove.show.push(`- ${entry.attrs.name}`);
283
302
  remove.send.push(`${menu} remove [ find name=${quote(entry.attrs.name!)} ]`);
@@ -20,3 +20,4 @@ Behaviour that looks like something else. Each one has been hit on a CE6855 runn
20
20
  - `abort` is not a command here. `clear configuration candidate` discards uncommitted changes.
21
21
  - sFlow samples per port, `sflow sampling rate N`, N from 4096; the default is 8192 and an explicit 8192 is kept as written. A port names a collector only once the collector exists globally; until then it is refused with "collector does not exist".
22
22
  - `vlan batch` holds every VLAN on one line, and undoing a list that names a VLAN with an L3 interface is refused. A change sends only the VLANs added and `undo vlan batch` for the ones removed.
23
+ - A VLAN name is unique across VLANs: a new VLAN cannot take a name another still holds. VLANs no longer used, and their VLAN interfaces, are removed first, in the same candidate.
@@ -722,21 +722,52 @@ export async function apply(device: Device, _network: readonly Device[], plan: P
722
722
  console.log(" committed, login proved, saved");
723
723
  }
724
724
 
725
+ /**
726
+ * What changes, in the order the platform accepts.
727
+ *
728
+ * A VLAN list changes by ID. VLANs no longer used go first, with their VLAN interfaces, so their names and numbers are free before a new VLAN takes them: a VLAN name is unique, and a VLAN with an interface cannot be removed. The whole plan is one candidate, so going first leaves nothing half done.
729
+ */
730
+ function plan(desired: string, current: string): Plan {
731
+ const groups = compare(desired, current, dialect);
732
+ vlanListChange(
733
+ groups,
734
+ "vlan batch ",
735
+ batchIds,
736
+ (ids) => [`vlan batch ${vlanBatch(ids)}`],
737
+ (ids) => [`vlan batch ${vlanBatch(ids)}`]
738
+ );
739
+
740
+ const top = groups.find((group) => group.path.length === 0);
741
+ const removedBatch = top?.remove.find((line) => line.startsWith("vlan batch ")) ?? "";
742
+ const removedIds = removedBatch ? batchIds(removedBatch) : [];
743
+
744
+ const firstRemovals = [
745
+ ...removedIds.map((id) => `interface Vlanif${id}`).filter((line) => top?.remove.includes(line)),
746
+ ...removedIds.map((id) => `vlan ${id}`).filter((line) => top?.remove.includes(line)),
747
+ ...(removedBatch ? [removedBatch] : []),
748
+ ];
749
+
750
+ if (top) top.remove = top.remove.filter((line) => !firstRemovals.includes(line));
751
+
752
+ const planned = steps(
753
+ groups.filter((group) => group.add.length || group.remove.length),
754
+ dialect
755
+ );
756
+
757
+ if (firstRemovals.length) {
758
+ // `undo vlan batch` removes a VLAN's own settings with it, so only the interfaces and the list are sent.
759
+ const sent = firstRemovals.filter((line) => !/^vlan \d+$/.test(line)).map(undo);
760
+ planned.unshift({ title: "VLANs no longer used", show: firstRemovals.map((line) => `- ${line}`), send: sent });
761
+ }
762
+
763
+ return { steps: planned, problems: [] };
764
+ }
765
+
725
766
  export const vrp: DeviceAdapter = {
726
767
  extension: ".cfg",
727
768
  unsupported,
728
769
  render,
729
770
  read,
730
- plan: (desired, current) => {
731
- const groups = compare(desired, current, dialect);
732
- vlanListChange(
733
- groups,
734
- "vlan batch ",
735
- batchIds,
736
- (ids) => [`vlan batch ${vlanBatch(ids)}`],
737
- (ids) => [`vlan batch ${vlanBatch(ids)}`]
738
- );
739
- return { steps: steps(groups, dialect), problems: [] };
740
- },
771
+ plan,
741
772
  apply,
742
773
  };
package/src/cli/index.ts CHANGED
File without changes