@takodotid/circuit 0.28.0 → 0.28.1

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/dist/cli/index.js CHANGED
@@ -324,8 +324,10 @@ async function openOnJump(console2, jump) {
324
324
  return {
325
325
  channel: asTerminal(channel),
326
326
  close: () => {
327
- channel.end();
328
- client.end();
327
+ quiet(channel, 1000).then(() => {
328
+ channel.end();
329
+ client.end();
330
+ });
329
331
  }
330
332
  };
331
333
  } catch (error) {
@@ -355,8 +357,10 @@ async function openHere(console2) {
355
357
  return {
356
358
  channel: asTerminal(asChannel),
357
359
  close: () => {
358
- channel.end();
359
- setTimeout(() => child.kill(), 2000).unref();
360
+ quiet(asChannel, 1000).then(() => {
361
+ channel.end();
362
+ setTimeout(() => child.kill(), 2000).unref();
363
+ });
360
364
  }
361
365
  };
362
366
  }
@@ -428,6 +432,7 @@ async function consoleLogin(channel, dialect, user, password) {
428
432
  return `${dialect.logout}
429
433
  `;
430
434
  };
435
+ await quiet(channel, 1000);
431
436
  let state = (await expect(channel, `
432
437
  `, states)).index;
433
438
  for (let step = 0;state !== 0; step++) {
@@ -435,6 +440,7 @@ async function consoleLogin(channel, dialect, user, password) {
435
440
  throw new Error("the console never reached a login prompt");
436
441
  state = (await expect(channel, toward(state), states)).index;
437
442
  }
443
+ await quiet(channel, 1000);
438
444
  const name = dialect.user ? dialect.user(user) : user;
439
445
  if ((await expect(channel, `${name}
440
446
  `, [dialect.password, dialect.login, dialect.prompt])).index !== 0) {
@@ -4566,7 +4572,7 @@ async function edit(channel, line, timeout = 60000) {
4566
4572
  return output.replace(/\r/g, "");
4567
4573
  }
4568
4574
  var JUNOS_CONSOLE = {
4569
- login: /login: $/,
4575
+ login: /(?:^|\n)login: $/,
4570
4576
  password: /Password:\s*$/,
4571
4577
  prompt: /[>%] $/,
4572
4578
  logout: "exit",
@@ -4613,7 +4619,7 @@ async function named(channel, line) {
4613
4619
  return [...names].map((name) => `delete ${[...words.slice(0, at), name, ...words.slice(at + 1)].join(" ")}`);
4614
4620
  }
4615
4621
  var REFUSED2 = /^terminal:\d+:\(\d+\) (?!warning)|^error: /m;
4616
- async function configure2(channel, plan, guard) {
4622
+ async function configure2(channel, plan, guard, paced = false) {
4617
4623
  const sent = plan.steps.flatMap((step) => step.send);
4618
4624
  const entered = await run(channel, "configure exclusive");
4619
4625
  if (!/Entering configuration mode/.test(entered) || /^error: /m.test(entered)) {
@@ -4639,9 +4645,13 @@ async function configure2(channel, plan, guard) {
4639
4645
  channel.write(`load set terminal
4640
4646
  `);
4641
4647
  await readUntil(channel, (text) => text.includes("[Type ^D at a new line to end input]"), 20000, 300);
4642
- for (const line of ordered)
4643
- channel.write(`${substitute(line)}
4648
+ for (const line of ordered) {
4649
+ const statement = substitute(line);
4650
+ channel.write(`${statement}
4644
4651
  `);
4652
+ if (paced)
4653
+ await readUntil(channel, (text) => text.replace(/\r/g, "").includes(statement.slice(-24)), 20000, 50);
4654
+ }
4645
4655
  const loaded = await edit(channel, "\x04", 300000);
4646
4656
  const refused = loaded.split(`
4647
4657
  `).filter((line) => REFUSED2.test(line));
@@ -4706,7 +4716,7 @@ async function apply2(device, _network, plan, options) {
4706
4716
  await relogin();
4707
4717
  await run(channel, "configure exclusive");
4708
4718
  }
4709
- });
4719
+ }, true);
4710
4720
  });
4711
4721
  return;
4712
4722
  }
@@ -32,7 +32,7 @@ export type Guard = {
32
32
  *
33
33
  * Deletions go before the statements that are set, so a path deleted and set again in one plan ends up set.
34
34
  */
35
- export declare function configure(channel: ClientChannel, plan: Plan, guard?: Guard): Promise<void>;
35
+ export declare function configure(channel: ClientChannel, plan: Plan, guard?: Guard, paced?: boolean): Promise<void>;
36
36
  export declare function apply(device: Device, _network: readonly Device[], plan: Plan, options: ApplyOptions): Promise<void>;
37
37
  export declare function unsupported(device: Device): string[];
38
38
  /** The plan that replaces a prefix list's entries with what a registry returned. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.28.0",
3
+ "version": "0.28.1",
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",
@@ -39,3 +39,4 @@ Behaviour that looks like something else. Each one has been hit on a FortiGate 3
39
39
  - `execute backup config flash <comment>` keeps a revision on the device, and `execute restore config flash <id>` restores one.
40
40
  - The ports are `port1` to `port4` at 40G and `port5` to `port32` at 10G, then `mgmt1` and `mgmt2`. The factory dedicates both management ports to management and gives them addresses `192.168.1.99` and `192.168.2.99`.
41
41
  - On the serial console a line ends at a carriage return. A session left open stays logged in, and one left inside `config` stays there, so the next login must first leave and log out. The console takes 9600 bits per second: reading the sections Circuit manages takes about two minutes.
42
+ - A data port can stay `netdev status: down`, with `rx_carrier: 0` in `diagnose hardware deviceinfo nic`, while the far end reads link-ok and frames reach the port's switch chip. Fixing the speed does not change it, in a LAG or out of one; a reboot does. `link_status: Up` there says nothing: a port with no cable shows it too.
@@ -29,3 +29,6 @@ Behaviour that looks like something else. Each one has been hit on a QFX5100-48S
29
29
  - IPv4 routes over an IPv6 session need a local IPv4 address under the family: `Local IPv4 address must be configured`.
30
30
  - A DHCP server and a DHCP relay cannot share an interface: `Interface irb.10 already configured`.
31
31
  - A port's MTU counts the Ethernet header. `mtu 9018` carries 9000-byte packets with a VLAN tag, and a VLAN interface's 9000 then commits without a warning.
32
+ - The serial console has no flow control. Statements pasted into `load set terminal` at once overrun it: some arrive garbled, the load reports syntax errors, and the `^D` that ends it can be lost, leaving the console in the load. Circuit sends each statement after the echo of the one before.
33
+ - After a login the console prints `Last login: ...`, which ends in `login: ` like the login prompt; only a `login: ` on a line of its own is the prompt.
34
+ - A factory-default switch runs Auto Image Upgrade, which prints on the console every few seconds until `delete chassis auto-image-upgrade` is committed.
@@ -360,7 +360,8 @@ async function edit(channel: ClientChannel, line: string, timeout = 60_000): Pro
360
360
 
361
361
  /** How the console asks for a login. Root lands in a shell, `%`, every other user in the CLI, `>`; a session left in configuration mode, `#`, leaves it first. Not yet proved on a device. */
362
362
  export const JUNOS_CONSOLE: ConsoleDialect = {
363
- login: /login: $/,
363
+ // On a line of its own: the banner after a login says `Last login: ...`, and a chunk can end right after it.
364
+ login: /(?:^|\n)login: $/,
364
365
  password: /Password:\s*$/,
365
366
  prompt: /[>%] $/,
366
367
  logout: "exit",
@@ -434,7 +435,7 @@ export type Guard = { minutes: number; prove: () => Promise<void> };
434
435
  *
435
436
  * Deletions go before the statements that are set, so a path deleted and set again in one plan ends up set.
436
437
  */
437
- export async function configure(channel: ClientChannel, plan: Plan, guard?: Guard): Promise<void> {
438
+ export async function configure(channel: ClientChannel, plan: Plan, guard?: Guard, paced = false): Promise<void> {
438
439
  const sent = plan.steps.flatMap((step) => step.send);
439
440
 
440
441
  const entered = await run(channel, "configure exclusive");
@@ -460,7 +461,12 @@ export async function configure(channel: ClientChannel, plan: Plan, guard?: Guar
460
461
  channel.write("load set terminal\n");
461
462
  await readUntil(channel, (text) => text.includes("[Type ^D at a new line to end input]"), 20_000, 300);
462
463
 
463
- for (const line of ordered) channel.write(`${substitute(line)}\n`);
464
+ // A serial console has no flow control: statements sent at once overrun it and arrive garbled. There, each waits for the echo of the one before.
465
+ for (const line of ordered) {
466
+ const statement = substitute(line);
467
+ channel.write(`${statement}\n`);
468
+ if (paced) await readUntil(channel, (text) => text.replace(/\r/g, "").includes(statement.slice(-24)), 20_000, 50);
469
+ }
464
470
 
465
471
  const loaded = await edit(channel, "\u0004", 300_000);
466
472
  const refused = loaded.split("\n").filter((line) => REFUSED.test(line));
@@ -518,14 +524,19 @@ export async function apply(device: Device, _network: readonly Device[], plan: P
518
524
  await checkVersion((command) => run(channel, command), device);
519
525
 
520
526
  // A fresh login on the console ends the session that holds the candidate, so the proof logs in again and takes the candidate back to confirm the commit.
521
- await configure(channel, plan, {
522
- minutes: options.rollback,
523
- prove: async () => {
524
- await run(channel, "exit configuration-mode");
525
- await relogin();
526
- await run(channel, "configure exclusive");
527
+ await configure(
528
+ channel,
529
+ plan,
530
+ {
531
+ minutes: options.rollback,
532
+ prove: async () => {
533
+ await run(channel, "exit configuration-mode");
534
+ await relogin();
535
+ await run(channel, "configure exclusive");
536
+ },
527
537
  },
528
- });
538
+ true
539
+ );
529
540
  });
530
541
  return;
531
542
  }
@@ -144,9 +144,12 @@ async function openOnJump(console: Console, jump: Jump): Promise<ConsoleSession>
144
144
 
145
145
  return {
146
146
  channel: asTerminal(channel),
147
+ // What was written last, such as a logout, is still on its way to the device; the relay ends once the console goes quiet.
147
148
  close: () => {
148
- channel.end();
149
- client.end();
149
+ void quiet(channel, 1_000).then(() => {
150
+ channel.end();
151
+ client.end();
152
+ });
150
153
  },
151
154
  };
152
155
  } catch (error) {
@@ -186,8 +189,10 @@ async function openHere(console: Console): Promise<ConsoleSession> {
186
189
  return {
187
190
  channel: asTerminal(asChannel),
188
191
  close: () => {
189
- channel.end();
190
- setTimeout(() => child.kill(), 2_000).unref();
192
+ void quiet(asChannel, 1_000).then(() => {
193
+ channel.end();
194
+ setTimeout(() => child.kill(), 2_000).unref();
195
+ });
191
196
  },
192
197
  };
193
198
  }
@@ -301,12 +306,18 @@ export async function consoleLogin(channel: Channel, dialect: ConsoleDialect, us
301
306
  return `${dialect.logout}\n`;
302
307
  };
303
308
 
309
+ // What the last session printed as it left, such as a login prompt after its logout, arrives late; it must not be taken for an answer.
310
+ await quiet(channel, 1_000);
311
+
304
312
  let state = (await expect(channel, "\n", states)).index;
305
313
  for (let step = 0; state !== 0; step++) {
306
314
  if (step >= 10) throw new Error("the console never reached a login prompt");
307
315
  state = (await expect(channel, toward(state), states)).index;
308
316
  }
309
317
 
318
+ // A console can print the login prompt more than once; a name typed while it does is lost.
319
+ await quiet(channel, 1_000);
320
+
310
321
  const name = dialect.user ? dialect.user(user) : user;
311
322
  if ((await expect(channel, `${name}\n`, [dialect.password, dialect.login, dialect.prompt])).index !== 0) {
312
323
  throw new Error("the console did not ask for a password");