@tikoci/centrs 0.1.8 → 0.1.9-next.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 (38) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +4 -0
  3. package/commands/api/README.md +5 -0
  4. package/commands/execute/README.md +117 -0
  5. package/commands/execute/examples.md +162 -0
  6. package/commands/retrieve/README.md +2 -1
  7. package/docs/CLI.md +2 -0
  8. package/docs/CONSTITUTION.md +29 -1
  9. package/docs/MATRIX.md +19 -3
  10. package/docs/errors/cmr/cancelled.md +15 -0
  11. package/docs/errors/cmr/client-disconnected.md +19 -0
  12. package/docs/errors/cmr/client-script-not-validated.md +22 -0
  13. package/docs/errors/cmr/console-only.md +29 -0
  14. package/docs/errors/cmr/empty-selection.md +19 -0
  15. package/docs/errors/cmr/membership-changed.md +20 -0
  16. package/docs/errors/cmr/not-a-controller.md +26 -0
  17. package/docs/errors/cmr/outcome-unknown.md +29 -0
  18. package/docs/errors/cmr/output-truncated.md +16 -0
  19. package/docs/errors/cmr/policy-denied.md +31 -0
  20. package/docs/errors/cmr/selection-incomplete.md +18 -0
  21. package/docs/errors/cmr/validation-incomplete.md +23 -0
  22. package/docs/errors/routeros/version-unsupported.md +4 -0
  23. package/docs/errors/transport/write-outcome-unknown.md +24 -0
  24. package/docs/errors/usage/missing-labels.md +19 -0
  25. package/package.json +1 -1
  26. package/src/api-fanout.ts +7 -1
  27. package/src/api.ts +25 -6
  28. package/src/cli/execute.ts +132 -1
  29. package/src/core/cmr.ts +104 -0
  30. package/src/core/envelope.ts +16 -0
  31. package/src/core/error-catalog.ts +71 -0
  32. package/src/core/fanout.ts +77 -8
  33. package/src/errors.ts +1 -0
  34. package/src/execute-cmr.ts +1425 -0
  35. package/src/execute-fanout.ts +4 -0
  36. package/src/execute.ts +68 -17
  37. package/src/index.ts +17 -0
  38. package/src/protocols/adapter.ts +54 -12
@@ -33,6 +33,7 @@ import {
33
33
  buildFanoutResolveFailure,
34
34
  commonVia,
35
35
  defaultFanoutSleep,
36
+ mayRetryUnsentWrite,
36
37
  resolveFanoutConcurrency,
37
38
  runFanout,
38
39
  summarizeFanout,
@@ -105,6 +106,7 @@ export interface ExecuteFanoutInternals {
105
106
  ) => Promise<SelectionExpansion>;
106
107
  execute?: (
107
108
  resolved: ResolvedExecuteRequest,
109
+ trace: ValidationTrace,
108
110
  ) => Promise<ExecuteSuccessEnvelope>;
109
111
  sleep?: (ms: number) => Promise<void>;
110
112
  }
@@ -308,6 +310,8 @@ export async function executeFanout(
308
310
  ),
309
311
  member,
310
312
  ),
313
+ mayRetry: (resolved) =>
314
+ mayRetryUnsentWrite(global.summary.write, traces.get(resolved)),
311
315
  sleep,
312
316
  });
313
317
 
package/src/execute.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import {
2
+ assertNotCmrDashboardIl,
3
+ assertNotCmrDashboardPath,
4
+ cmrRunScriptWarnings,
5
+ cmrScriptWarnings,
6
+ } from "./core/cmr.ts";
1
7
  import type {
2
8
  CentrsEnvelope,
3
9
  CentrsErrorEnvelope,
@@ -16,6 +22,7 @@ import {
16
22
  canonicalizeExecuteCommand,
17
23
  isWriteShaped,
18
24
  } from "./core/execute-command.ts";
25
+ import { sentWriteWarnings } from "./core/fanout.ts";
19
26
  import { inspectArgumentNames, pathTokens } from "./core/inspect.ts";
20
27
  import { mapRouterOsResultError } from "./core/routeros-errors.ts";
21
28
  import { routerOsStringLiteral } from "./core/routeros-string.ts";
@@ -37,6 +44,7 @@ import {
37
44
  getProtocolPlan,
38
45
  type ProtocolAdapter,
39
46
  type ProtocolExecuteResult,
47
+ type ProtocolSendOptions,
40
48
  plannedProtocols,
41
49
  type RouterOsProtocol,
42
50
  } from "./protocols/index.ts";
@@ -288,7 +296,11 @@ export async function runResolvedExecute(
288
296
  // reports the gate that actually passed rather than a guess.
289
297
  trace.validation = validation;
290
298
 
291
- const result = await runCommand(resolved, backend);
299
+ const result = await runCommand(resolved, backend, {
300
+ onSent: () => {
301
+ trace.dispatched = true;
302
+ },
303
+ });
292
304
  const routerOsFailure = routerOsFailureFromResult(
293
305
  result,
294
306
  resolved.via.value,
@@ -319,6 +331,7 @@ export async function resolveExecuteRequest(
319
331
  ): Promise<ResolvedExecuteRequest> {
320
332
  validateExecuteRequestShape(request);
321
333
  const canonical = canonicalizeExecuteCommand(request.command);
334
+ assertNotStructuredDashboard(canonical);
322
335
  const config = options.config ?? (await loadEnvFileDefaults(env));
323
336
  // A quickchr target is a named-live-provider: it supplies host/port/auth from
324
337
  // the live descriptor and bypasses CDB/env resolution for those fields
@@ -450,9 +463,12 @@ export async function resolveExecuteRequest(
450
463
  maxResultsBytes,
451
464
  yes: request.yes ?? false,
452
465
  verbose: request.verbose ?? false,
453
- warnings: connection
454
- ? [...connection.warnings]
455
- : (cdbResolution?.warnings ?? []),
466
+ warnings: [
467
+ ...(connection ? connection.warnings : (cdbResolution?.warnings ?? [])),
468
+ ...(canonical.mode === "structured"
469
+ ? cmrRunScriptWarnings(`${canonical.path}/${canonical.verb}`)
470
+ : cmrScriptWarnings(canonical.input)),
471
+ ],
456
472
  };
457
473
  }
458
474
 
@@ -477,6 +493,7 @@ export function resolveExecuteGlobalContext(
477
493
  ): ExecuteGlobalContext {
478
494
  validateExecuteRequestShape(request);
479
495
  const canonical = canonicalizeExecuteCommand(request.command);
496
+ assertNotStructuredDashboard(canonical);
480
497
  const via = resolveExecuteProtocol(request, env, undefined, config);
481
498
  const format = resolveFormat(request, env, config);
482
499
  const validate = resolveBooleanSetting(
@@ -599,7 +616,14 @@ export function buildExecuteErrorEnvelopeFromResolved(
599
616
  return {
600
617
  ok: false,
601
618
  error: serializeCentrsError(centrsError),
602
- warnings: [...resolved.warnings],
619
+ warnings: [
620
+ ...resolved.warnings,
621
+ ...sentWriteWarnings(
622
+ isWriteShaped(resolved.canonical),
623
+ centrsError,
624
+ trace,
625
+ ),
626
+ ],
603
627
  tips: resolvedExecuteTips(resolved),
604
628
  meta: metaFromResolved(
605
629
  resolved,
@@ -729,7 +753,7 @@ function deviceStageSource(resolved: ResolvedExecuteRequest): string {
729
753
  * judge. An absent verdict means the caller did not thread a trace, so say
730
754
  * nothing extra rather than invent the reason.
731
755
  */
732
- function offlineStagePassed(
756
+ export function offlineStagePassed(
733
757
  verdict: ValidationTrace["offlineVerdict"],
734
758
  ): EnvelopeValidationStage {
735
759
  return {
@@ -748,7 +772,7 @@ function offlineStagePassed(
748
772
  * a consumer reading `stages` never has to special-case the disabled shape.
749
773
  * `reason` is left off — `enabled: false` already says why.
750
774
  */
751
- function disabledValidationMeta(): EnvelopeValidationMeta {
775
+ export function disabledValidationMeta(): EnvelopeValidationMeta {
752
776
  return {
753
777
  enabled: false,
754
778
  source: "disabled",
@@ -888,6 +912,7 @@ async function validateConsoleParseCommand(
888
912
  });
889
913
  // Throws validation/syntax or validation/unknown-attribute on a bad parse.
890
914
  classifyParseResult(result.ret ?? "", resolved.command, resolved.via.value);
915
+ assertNotCmrDashboardIl(result.ret ?? "", resolved.command);
891
916
  return {
892
917
  enabled: true,
893
918
  source: `:put [:parse ...] over ${resolved.via.value}`,
@@ -908,7 +933,7 @@ async function validateConsoleParseCommand(
908
933
  * `warn` is an abstention, and saying so is the difference between "the analyzer
909
934
  * read all of this" and "it declined to judge part of it".
910
935
  */
911
- function passedStages(
936
+ export function passedStages(
912
937
  offline: OfflineGateResult,
913
938
  deviceSource: string,
914
939
  ): EnvelopeValidationStage[] {
@@ -939,6 +964,7 @@ export async function validateRouterOsScript(
939
964
  via: RouterOsProtocol,
940
965
  ): Promise<void> {
941
966
  const script = `:put [:parse ${routerOsStringLiteral(command)}]`;
967
+ let il: string;
942
968
  try {
943
969
  // GH#230: `:parse` never throws (CHR 7.23.3). The diagnostic rides the
944
970
  // success value's text — over REST/native the `ret` field (HTTP 200 /
@@ -947,7 +973,8 @@ export async function validateRouterOsScript(
947
973
  // Read the return and classify it: one `:parse` covers syntax and the
948
974
  // unknown-attribute (name-level) gate.
949
975
  const result = await backend.execute({ path: "", command: "", script });
950
- classifyParseResult(result.ret ?? "", command, via);
976
+ il = result.ret ?? "";
977
+ classifyParseResult(il, command, via);
951
978
  } catch (error) {
952
979
  // The preflight is a syntax gate, but `backend.execute` also surfaces
953
980
  // connection and authentication failures (login happens lazily here).
@@ -984,6 +1011,20 @@ export async function validateRouterOsScript(
984
1011
  causeData: syntaxCause(error),
985
1012
  });
986
1013
  }
1014
+ assertNotCmrDashboardIl(il, command);
1015
+ }
1016
+
1017
+ /**
1018
+ * The plain spellings of the dashboard are refused before anything is dialed.
1019
+ * Every other spelling (a relative menu change, a block, a substitution) is
1020
+ * found in the `:parse` return, so it is refused by validation.
1021
+ */
1022
+ function assertNotStructuredDashboard(
1023
+ canonical: CanonicalExecuteCommand,
1024
+ ): void {
1025
+ if (canonical.mode === "structured") {
1026
+ assertNotCmrDashboardPath(`${canonical.path}/${canonical.verb}`);
1027
+ }
987
1028
  }
988
1029
 
989
1030
  /**
@@ -998,22 +1039,32 @@ function isPreflightTransportError(error: CentrsError): boolean {
998
1039
  async function runCommand(
999
1040
  resolved: ResolvedExecuteRequest,
1000
1041
  backend: ProtocolAdapter,
1042
+ options: ProtocolSendOptions,
1001
1043
  ): Promise<ProtocolExecuteResult> {
1002
1044
  if (resolved.via.value === "mac-telnet" || resolved.via.value === "ssh") {
1003
1045
  // Console transports: run the raw CLI line and capture the console output
1004
1046
  // (structured path-POST / tagged talk do not apply). ssh runs it as a
1005
1047
  // single `ssh host "<command>"`; mac-telnet drives the L2 console.
1006
- return backend.execute({ path: "", command: "", script: resolved.command });
1048
+ return backend.execute(
1049
+ { path: "", command: "", script: resolved.command },
1050
+ options,
1051
+ );
1007
1052
  }
1008
1053
  if (resolved.canonical.mode === "script") {
1009
- return backend.execute({ path: "", command: "", script: resolved.command });
1054
+ return backend.execute(
1055
+ { path: "", command: "", script: resolved.command },
1056
+ options,
1057
+ );
1010
1058
  }
1011
- return backend.execute({
1012
- path: resolved.canonical.path,
1013
- command: resolved.canonical.verb,
1014
- attributes: resolved.canonical.attributes,
1015
- queries: resolved.canonical.queries,
1016
- });
1059
+ return backend.execute(
1060
+ {
1061
+ path: resolved.canonical.path,
1062
+ command: resolved.canonical.verb,
1063
+ attributes: resolved.canonical.attributes,
1064
+ queries: resolved.canonical.queries,
1065
+ },
1066
+ options,
1067
+ );
1017
1068
  }
1018
1069
 
1019
1070
  async function assertWriteConfirmed(
package/src/index.ts CHANGED
@@ -286,6 +286,23 @@ export {
286
286
  runResolvedExecute,
287
287
  validateExecuteRequestShape,
288
288
  } from "./execute.ts";
289
+ export {
290
+ buildExecuteCmrErrorEnvelope,
291
+ CMR_VALIDATION_SOURCE,
292
+ type CmrController,
293
+ type CmrDevice,
294
+ type CmrScriptCall,
295
+ type CmrScriptOutcome,
296
+ type CmrScriptRow,
297
+ type ExecuteCmrData,
298
+ type ExecuteCmrEnvelope,
299
+ type ExecuteCmrErrorEnvelope,
300
+ type ExecuteCmrInternals,
301
+ type ExecuteCmrOperationMeta,
302
+ type ExecuteCmrRequest,
303
+ executeCmr,
304
+ renderExecuteCmrEnvelope,
305
+ } from "./execute-cmr.ts";
289
306
  export {
290
307
  buildExecuteFanoutErrorEnvelope,
291
308
  type ExecuteFanoutData,
@@ -180,6 +180,16 @@ export interface ProtocolApiResult {
180
180
  data: unknown;
181
181
  }
182
182
 
183
+ /**
184
+ * Options for a single `execute`/`apiRequest` call. `onSent` fires once the
185
+ * command itself is about to leave for the router: after a native or console
186
+ * login, never during it. A failure before it fired cannot have run the
187
+ * command (#448).
188
+ */
189
+ export interface ProtocolSendOptions {
190
+ onSent?: () => void;
191
+ }
192
+
183
193
  /** Options for incremental native command replies. */
184
194
  export interface ProtocolListenOptions {
185
195
  /** Abort the listen (sends `/cancel`); the generator then ends cleanly. */
@@ -237,9 +247,15 @@ export interface ProtocolAdapter {
237
247
  /** Read a menu as an array of records, optionally projected/detailed. */
238
248
  list(path: string, options: RetrieveListOptions): Promise<unknown[]>;
239
249
  /** Run a CLI-shaped command (WP-1c write surface). */
240
- execute(request: ProtocolExecuteRequest): Promise<ProtocolExecuteResult>;
250
+ execute(
251
+ request: ProtocolExecuteRequest,
252
+ options?: ProtocolSendOptions,
253
+ ): Promise<ProtocolExecuteResult>;
241
254
  /** Run a normalized structured `api` request (the gh-api passthrough surface). */
242
- apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult>;
255
+ apiRequest(
256
+ request: ProtocolApiRequest,
257
+ options?: ProtocolSendOptions,
258
+ ): Promise<ProtocolApiResult>;
243
259
  /**
244
260
  * Incremental native replies to the literal command (the same mapping as
245
261
  * `apiRequest`; never rewritten into a `listen`). Yields each `!re` and
@@ -341,7 +357,10 @@ class RestAdapter implements ProtocolAdapter {
341
357
 
342
358
  async execute(
343
359
  request: ProtocolExecuteRequest,
360
+ options?: ProtocolSendOptions,
344
361
  ): Promise<ProtocolExecuteResult> {
362
+ // REST has no session to open first: the request is the command.
363
+ options?.onSent?.();
345
364
  if (request.script !== undefined) {
346
365
  const data = await this.restPost<unknown>("/execute", {
347
366
  script: request.script,
@@ -369,7 +388,11 @@ class RestAdapter implements ProtocolAdapter {
369
388
  return normalizeRestExecute(data);
370
389
  }
371
390
 
372
- async apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult> {
391
+ async apiRequest(
392
+ request: ProtocolApiRequest,
393
+ options?: ProtocolSendOptions,
394
+ ): Promise<ProtocolApiResult> {
395
+ options?.onSent?.();
373
396
  const base = request.path.replace(/\/$/, "");
374
397
  switch (request.verb) {
375
398
  case "print": {
@@ -765,9 +788,12 @@ class NativeApiAdapter implements ProtocolAdapter {
765
788
 
766
789
  async execute(
767
790
  request: ProtocolExecuteRequest,
791
+ options?: ProtocolSendOptions,
768
792
  ): Promise<ProtocolExecuteResult> {
769
793
  if (request.script !== undefined) {
770
- return normalizeNativeExecute(await this.executeScript(request.script));
794
+ return normalizeNativeExecute(
795
+ await this.executeScript(request.script, options),
796
+ );
771
797
  }
772
798
  const command: NativeApiCommand = {
773
799
  command: `${request.path.replace(/\/$/, "")}/${request.command}`,
@@ -781,7 +807,7 @@ class NativeApiAdapter implements ProtocolAdapter {
781
807
  if (request.binary) {
782
808
  command.binary = true;
783
809
  }
784
- const replies = await this.talk(command);
810
+ const replies = await this.talk(command, options);
785
811
  return { records: repliesToRecords(replies) };
786
812
  }
787
813
 
@@ -794,17 +820,24 @@ class NativeApiAdapter implements ProtocolAdapter {
794
820
  */
795
821
  private async executeScript(
796
822
  script: string,
823
+ options?: ProtocolSendOptions,
797
824
  ): Promise<Record<string, string>[]> {
798
- const replies = await this.talk({
799
- command: "/execute",
800
- attributes: { script, "as-string": "" },
801
- });
825
+ const replies = await this.talk(
826
+ {
827
+ command: "/execute",
828
+ attributes: { script, "as-string": "" },
829
+ },
830
+ options,
831
+ );
802
832
  return repliesToRecords(replies);
803
833
  }
804
834
 
805
- async apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult> {
835
+ async apiRequest(
836
+ request: ProtocolApiRequest,
837
+ options?: ProtocolSendOptions,
838
+ ): Promise<ProtocolApiResult> {
806
839
  const records = repliesToRecords(
807
- await this.talk(nativeCommandFor(request)),
840
+ await this.talk(nativeCommandFor(request), options),
808
841
  );
809
842
  switch (request.verb) {
810
843
  case "print":
@@ -880,8 +913,13 @@ class NativeApiAdapter implements ProtocolAdapter {
880
913
  return session;
881
914
  }
882
915
 
883
- private async talk(command: NativeApiCommand): Promise<ApiReply[]> {
916
+ private async talk(
917
+ command: NativeApiCommand,
918
+ options?: ProtocolSendOptions,
919
+ ): Promise<ApiReply[]> {
920
+ // Connecting logs in lazily; only the sentence after `/login` is the command.
884
921
  const session = await this.connect();
922
+ options?.onSent?.();
885
923
  return this.withTimeout(session.talk(command));
886
924
  }
887
925
 
@@ -982,6 +1020,7 @@ class MacTelnetAdapter implements ProtocolAdapter {
982
1020
 
983
1021
  async execute(
984
1022
  request: ProtocolExecuteRequest,
1023
+ options?: ProtocolSendOptions,
985
1024
  ): Promise<ProtocolExecuteResult> {
986
1025
  const cli = request.script;
987
1026
  if (cli === undefined) {
@@ -994,6 +1033,7 @@ class MacTelnetAdapter implements ProtocolAdapter {
994
1033
  });
995
1034
  }
996
1035
  const console = await this.open();
1036
+ options?.onSent?.();
997
1037
  const { output } = await console.run(cli);
998
1038
  return { records: [], ret: output };
999
1039
  }
@@ -1134,6 +1174,7 @@ class SshExecAdapter implements ProtocolAdapter {
1134
1174
 
1135
1175
  async execute(
1136
1176
  request: ProtocolExecuteRequest,
1177
+ options?: ProtocolSendOptions,
1137
1178
  ): Promise<ProtocolExecuteResult> {
1138
1179
  const cli = request.script;
1139
1180
  if (cli === undefined) {
@@ -1145,6 +1186,7 @@ class SshExecAdapter implements ProtocolAdapter {
1145
1186
  "Report this bug; runCommand must pass the raw command as `script` for ssh.",
1146
1187
  });
1147
1188
  }
1189
+ options?.onSent?.();
1148
1190
  const output = await this.client.exec(cli);
1149
1191
  return { records: [], ret: output };
1150
1192
  }