@edgehero/pi-dispatch 1.10.3 → 2.1.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 (101) hide show
  1. package/.env.example +303 -150
  2. package/README.md +52 -0
  3. package/deploy/com.pi-dispatch.worker.plist +10 -4
  4. package/deploy/docker-compose.yml +49 -16
  5. package/deploy/egress-proxy.conf +32 -2
  6. package/deploy/nssm-install.cmd +12 -6
  7. package/deploy/pi-dispatch-egress-out.network +10 -0
  8. package/deploy/pi-dispatch-egress-proxy.container +50 -0
  9. package/deploy/pi-dispatch-netns-keeper.container +80 -0
  10. package/deploy/pi-dispatch-netns-keeper.network +18 -0
  11. package/deploy/pi-dispatch-valkey.container +51 -0
  12. package/deploy/pi-dispatch-valkey.network +16 -0
  13. package/deploy/receiver.service +6 -0
  14. package/deploy/worker-env-wrapper.cmd +12 -1
  15. package/deploy/worker-env-wrapper.sh +63 -37
  16. package/deploy/worker.service +18 -8
  17. package/package.json +15 -5
  18. package/src/azure-host.mjs +19 -0
  19. package/src/azure-identity.mjs +18 -2
  20. package/src/backend-conformance.mjs +71 -18
  21. package/src/backend-local.mjs +637 -21
  22. package/src/backend-podman.mjs +1168 -0
  23. package/src/backend-registry.mjs +86 -3
  24. package/src/backends.mjs +489 -37
  25. package/src/branch.mjs +7 -2
  26. package/src/cancel-cli.mjs +174 -0
  27. package/src/cancel-state.mjs +125 -0
  28. package/src/cli.mjs +188 -90
  29. package/src/config.mjs +503 -43
  30. package/src/connection.mjs +374 -8
  31. package/src/container-spec.mjs +102 -7
  32. package/src/daemon-facts.mjs +167 -0
  33. package/src/deployment-venue.mjs +158 -0
  34. package/src/docker-run.mjs +146 -15
  35. package/src/doctor.mjs +4756 -394
  36. package/src/egress-conf-copy.mjs +166 -0
  37. package/src/egress-proxy-state.mjs +151 -0
  38. package/src/egress.mjs +456 -25
  39. package/src/entry.mjs +27 -0
  40. package/src/env-allowlist.mjs +245 -40
  41. package/src/env-file.mjs +1869 -33
  42. package/src/exit-code.mjs +15 -0
  43. package/src/flow-gate.mjs +5 -3
  44. package/src/forgejo-host.mjs +19 -0
  45. package/src/forgejo-identity.mjs +21 -2
  46. package/src/get-token.mjs +67 -18
  47. package/src/git-dirty.mjs +9 -1
  48. package/src/git-hardening.mjs +33 -0
  49. package/src/github-app-setup.mjs +29 -12
  50. package/src/github-prompt.mjs +4 -1
  51. package/src/gitlab-host.mjs +19 -0
  52. package/src/gitlab-identity.mjs +19 -2
  53. package/src/host-pi.mjs +19 -3
  54. package/src/host-registry.mjs +29 -2
  55. package/src/identity.mjs +29 -4
  56. package/src/image-preflight.mjs +46 -11
  57. package/src/image-ref.mjs +21 -0
  58. package/src/index.mjs +363 -13
  59. package/src/init.mjs +197 -38
  60. package/src/job-user.mjs +252 -0
  61. package/src/json-duplicates.mjs +204 -0
  62. package/src/live-probes.mjs +1020 -0
  63. package/src/materialize.mjs +4 -11
  64. package/src/netns-keeper.mjs +264 -0
  65. package/src/on-failure.mjs +119 -0
  66. package/src/outbox.mjs +7 -0
  67. package/src/packages.mjs +2 -2
  68. package/src/podman-stack.mjs +1304 -0
  69. package/src/prepare-github.mjs +6 -6
  70. package/src/prepare-local.mjs +51 -17
  71. package/src/prepare.mjs +27 -6
  72. package/src/pricing.mjs +9 -5
  73. package/src/processor.mjs +506 -26
  74. package/src/provider-key.mjs +66 -0
  75. package/src/provider-steering.mjs +185 -0
  76. package/src/queue.mjs +35 -8
  77. package/src/redact.mjs +84 -0
  78. package/src/reserved-env.mjs +7 -3
  79. package/src/retention-sweep.mjs +178 -0
  80. package/src/run-container.mjs +181 -14
  81. package/src/run-history.mjs +105 -16
  82. package/src/runtime-observations.mjs +1152 -0
  83. package/src/runtime-settings.mjs +13 -8
  84. package/src/sandbox-cli.mjs +100 -95
  85. package/src/sandbox-store.mjs +612 -45
  86. package/src/sandbox.mjs +1459 -37
  87. package/src/schedules.mjs +16 -3
  88. package/src/secret-profiles.mjs +2 -1
  89. package/src/secrets.mjs +24 -6
  90. package/src/service-env.mjs +247 -0
  91. package/src/service.mjs +618 -28
  92. package/src/session-store.mjs +678 -53
  93. package/src/start.mjs +1348 -326
  94. package/src/subscriptions.mjs +7 -3
  95. package/src/transient.mjs +240 -0
  96. package/src/triggers-file.mjs +71 -15
  97. package/src/triggers.mjs +179 -19
  98. package/src/up.mjs +1399 -85
  99. package/src/valkey-auth.mjs +529 -0
  100. package/src/valkey-endpoint.mjs +367 -0
  101. package/src/watch-closer.mjs +158 -0
@@ -1,3 +1,6 @@
1
+ // The one import this module takes, and it is an import-free leaf for that reason (issue #339).
2
+ import { scrubCredentials } from "./redact.mjs";
3
+
1
4
  /**
2
5
  * Reap several backends and combine the tri-state CONSERVATIVELY.
3
6
  *
@@ -29,13 +32,80 @@ export async function reapAll(reaps = [], { log = () => {} } = {}) {
29
32
  // catch reached the caller before this function existed, and the caller logged it. Swallowing it
30
33
  // here without a word would delete an operator-visible signal about a venue that could not be
31
34
  // swept, so the log seam is threaded through rather than assumed to be somebody else's job.
32
- log("reaper_skipped", { reason: err?.message });
35
+ log("reaper_skipped", { reason: scrubCredentials(err?.message) });
33
36
  reaped = false;
34
37
  }
35
38
  }
36
39
  return { reaped };
37
40
  }
38
41
 
42
+ /**
43
+ * The `code` on the error the registry throws for a venue it does not hold, and ONLY that error.
44
+ *
45
+ * A CODE, not a message match: the processor catches this one refusal at pickup (a venue this worker never
46
+ * built is refused and recorded rather than retried) and must let every OTHER throw through -- a registered
47
+ * venue's own `containerName` failing is a broken adapter, and swallowing that would run the job under a
48
+ * null container name that nothing could stop by name.
49
+ */
50
+ export const BACKEND_NOT_REGISTERED = "BACKEND_NOT_REGISTERED";
51
+
52
+ /**
53
+ * The per-venue preflights a bundle MAY carry (issue #354), each with what a venue that carries none answers.
54
+ *
55
+ * OPTIONAL, not in the required list, because requiring them would break every adapter written against the
56
+ * five-function contract `docs/backends.md` publishes, and because the absent answer is not a guess: it is what
57
+ * every non-local venue got before this, when both were `start.mjs` closures that returned exactly these for
58
+ * any venue but `local`. `observationPreflight` admits (nothing about this host is observed for a venue with no
59
+ * `observedBy`), and `jobUserPreflight` runs the image's own USER. Kept equal to the processor's own defaults for
60
+ * those two deps, so a wiring that omits the registry and one whose venue omits the member agree.
61
+ *
62
+ * What absence cannot be is SAFE for a venue whose table entry is observation-gated: that venue's words would
63
+ * then hold with nothing observing them. `start.mjs` refuses such a bundle at boot, because this module cannot
64
+ * read the table (it imports only the redactor).
65
+ */
66
+ export const OPTIONAL_PREFLIGHT_DEFAULTS = Object.freeze({
67
+ observationPreflight: () => ({ ok: true }),
68
+ jobUserPreflight: () => ({ user: null, home: null }),
69
+ });
70
+
71
+ /** The venue's own member when it carries one, else the absent answer, as a promise either way. */
72
+ async function optionalPreflight(bundle, fn, ...args) {
73
+ return typeof bundle[fn] === "function" ? bundle[fn](...args) : OPTIONAL_PREFLIGHT_DEFAULTS[fn]();
74
+ }
75
+
76
+ /**
77
+ * The NAME of the venue a job resolves to: its own `run.backend`, else the deployment default.
78
+ *
79
+ * ONE DERIVATION FOR DISPATCH AND FOR EVERY STORE THAT RECORDS A VENUE (issue #277). The registry dispatches
80
+ * on it and the run record writes it down. A copy of the expression per store would be one more place a
81
+ * later change could land in one and not the others, and the whole value of a recorded venue is that it IS
82
+ * what dispatched: a record naming a venue the registry did not choose is an audit trail that lies.
83
+ *
84
+ * ABSENT MEANS THE KEY IS ABSENT, `undefined` and nothing else. The loader writes no `backend` key for a
85
+ * trigger that names no venue, and the processor's blessed gate tests `!== undefined`, so an explicit `null`
86
+ * is a NAME the gate refuses rather than a request for the default. Resolving it with `??` would record the
87
+ * default on a job the processor refused for naming something else. No producer writes a null today; this
88
+ * keeps the three readers agreeing if one ever does.
89
+ *
90
+ * TAKES JOB DATA, NEVER THE BULLMQ WRAPPER. A wrapper's own keys are `id` and `data`, so `wrapper.backend`
91
+ * is always undefined and every job would resolve to the default in silence -- the defect `index.mjs`
92
+ * records beside `containerName`, which shipped once. Callers holding a wrapper pass `wrapper.data`.
93
+ *
94
+ * `null` WHEN NEITHER IS KNOWN, never a guessed `local`. Only a dependency-injection seam reaches that (every
95
+ * wired caller is handed `config.defaultBackend`), and a consumer must fail closed on it: the record stores
96
+ * the null, and the registry refuses a job it cannot name. A default here would hide a dropped wire today and
97
+ * become a wrong answer the day `PI_BACKENDS` names a remote venue first.
98
+ *
99
+ * HERE rather than in `backends.mjs`, whose `backendFor(name)` falls back to the TABLE's default -- a
100
+ * different question, and putting the two side by side invites answering one with the other. And a free
101
+ * function rather than a registry method, because a store constructed in `startWorker` before any bundle
102
+ * exists could not reach one -- the temporal dead zone `reapAll` above already records.
103
+ */
104
+ export function resolveBackendName(data, defaultName) {
105
+ const named = data?.backend;
106
+ return named !== undefined ? named : (defaultName ?? null);
107
+ }
108
+
39
109
  /**
40
110
  * WHICH backend runs THIS job, and the one place that decides (issue #227).
41
111
  *
@@ -83,6 +153,12 @@ export function makeBackendRegistry({ bundles = [], defaultName, blessed = null,
83
153
  // exactly the case the list exists to refund.
84
154
  throw new Error(`backend registry: ${JSON.stringify(bundle.name)} must declare neverStartedExits as an array of integers ([] if it normalises to container-never-started itself)`);
85
155
  }
156
+ // OPTIONAL, but a present one must be callable (issue #354). Absent is a real answer, today's non-local
157
+ // behaviour (see `OPTIONAL_PREFLIGHT_DEFAULTS`); a truthy non-function is a typo'd adapter, and admitting it would
158
+ // land as a TypeError on the pre-spend path at the first pickup, the failure the required list above exists for.
159
+ for (const fn of Object.keys(OPTIONAL_PREFLIGHT_DEFAULTS)) {
160
+ if (bundle[fn] !== undefined && typeof bundle[fn] !== "function") throw new Error(`backend registry: ${JSON.stringify(bundle.name)} has a ${fn} that is not a function`);
161
+ }
86
162
  byName.set(bundle.name, bundle);
87
163
  }
88
164
  if (byName.size === 0) throw new Error("backend registry: at least one backend must be registered");
@@ -117,10 +193,12 @@ export function makeBackendRegistry({ bundles = [], defaultName, blessed = null,
117
193
 
118
194
  /** The bundle this job runs in. Throws for a name no gate should have let through. */
119
195
  function backendFor(job) {
120
- const name = job?.backend ?? defaultName;
196
+ const name = resolveBackendName(job, defaultName);
121
197
  const bundle = byName.get(name);
122
198
  if (!bundle) {
123
- throw new Error(`backend registry: no backend named ${JSON.stringify(name)} is registered (have: ${[...byName.keys()].join(", ")})`);
199
+ const err = new Error(`backend registry: no backend named ${JSON.stringify(name)} is registered (have: ${[...byName.keys()].join(", ")})`);
200
+ err.code = BACKEND_NOT_REGISTERED;
201
+ throw err;
124
202
  }
125
203
  return bundle;
126
204
  }
@@ -140,6 +218,11 @@ export function makeBackendRegistry({ bundles = [], defaultName, blessed = null,
140
218
  runContainer: (args) => backendFor(args?.job).runContainer(args),
141
219
  imagePreflight: (job) => backendFor(job).imagePreflight(job),
142
220
  egressPreflight: (job) => backendFor(job).egressPreflight(job),
221
+ // The two OPTIONAL per-venue preflights (issue #354), dispatched through the same resolution as every
222
+ // required one, so an unregistered name still throws BACKEND_NOT_REGISTERED rather than being waved
223
+ // through by a default. A venue that carries none gets the answer the processor's own defaults give.
224
+ observationPreflight: (job) => optionalPreflight(backendFor(job), "observationPreflight", job),
225
+ jobUserPreflight: (job, opts) => optionalPreflight(backendFor(job), "jobUserPreflight", job, opts),
143
226
  stopContainer: (name, job) => backendFor(job).stopContainer(name, job),
144
227
  // ON THE SURFACE, not reached for through `backendFor` by a call site. Both are per-job backend
145
228
  // FACTS rather than functions, and an earlier draft left them off: the wiring rebuilt