@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,6 +1,6 @@
1
1
  import * as nodeFs from "node:fs";
2
2
  import { dirname } from "node:path";
3
- import { defaultSettingsFile } from "./config.mjs";
3
+ import { ensureUnderAccountRoot } from "./config.mjs";
4
4
 
5
5
  /**
6
6
  * Runtime-settings overlay: the shared, durable truth between the admin extension and the worker
@@ -56,13 +56,15 @@ function isIntInRange(value, min, max) {
56
56
  }
57
57
 
58
58
  /**
59
- * The absolute path of the settings overlay. Mirrors config.mjs: `PI_SETTINGS_FILE` wins, an unset or
60
- * empty value falls back to the shared default so the admin extension and the worker resolve the same
61
- * path without either coupling to the other.
59
+ * The absolute path of the settings overlay: `PI_SETTINGS_FILE` wins, an unset or empty value falls back
60
+ * to the durable default.
61
+ *
62
+ * RE-EXPORTED, not re-derived. It used to be a second copy of `config.mjs`'s expression, and two copies
63
+ * of a defaulting rule are how a panel and a worker come to read different files. This module keeps the
64
+ * name because `@edgehero/pi-dispatch/runtime-settings` is the import path the admin and the export-map
65
+ * probe already use; config.mjs owns the rule because it owns the default the rule falls back to.
62
66
  */
63
- export function settingsFilePath(env = process.env) {
64
- return env.PI_SETTINGS_FILE || defaultSettingsFile();
65
- }
67
+ export { settingsFilePath } from "./config.mjs";
66
68
 
67
69
  /**
68
70
  * Validate a parsed overlay against the key contract, returning the sanitized overlay (known keys only)
@@ -201,9 +203,12 @@ export function writeOverlay(path, candidate, { fs = nodeFs, log = () => {} } =
201
203
  if (result.invalid) return { invalid: result.invalid };
202
204
 
203
205
  try {
206
+ // Issue #464: on an account with no home the overlay's default lives in the per-account temp root, which is made
207
+ // this account's (0700) or refused, as the worker's boot does, before a file that outranks .env is written there.
208
+ ensureUnderAccountRoot(dirname(path), { fs: { mkdirSync: fs.mkdirSync, lstatSync: fs.lstatSync ?? nodeFs.lstatSync, statSync: fs.statSync ?? nodeFs.statSync, chmodSync: fs.chmodSync ?? nodeFs.chmodSync } });
204
209
  fs.mkdirSync(dirname(path), { recursive: true });
205
210
  } catch (err) {
206
- return { invalid: `settings dir unwritable (${err?.code ?? "mkdir-error"})` };
211
+ return { invalid: err?.piDispatchConfig ? `settings dir refused: ${err.message}` : `settings dir unwritable (${err?.code ?? "mkdir-error"})` };
207
212
  }
208
213
 
209
214
  const tmp = `${path}.tmp`;
@@ -1,29 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { parseArgs } from "node:util";
3
- import { DEFAULT_BACKEND, backendFor } from "./backends.mjs";
4
3
  import { loadConfig } from "./config.mjs";
5
- import { sanitizeJobId } from "./run-history.mjs";
6
- import { createJobNetwork, egressEnv, networkNameFor, removeJobNetwork } from "./egress.mjs";
7
- import { buildSandboxRunArgs, launchSandbox, listRunningSandboxes, parsePublish, resolveSandbox, sandboxContainerName } from "./sandbox.mjs";
8
- import { listSandboxes, pinSandbox } from "./sandbox-store.mjs";
9
-
10
- /**
11
- * Would this deployment's venues put a job's retained directory out of this command's reach? Exported ONLY
12
- * so it can be driven.
13
- *
14
- * The refusal it answers is unreachable today: the table holds one backend and it is local, so
15
- * `PI_BACKENDS` cannot name a remote venue. An unreachable rule with no test is a rule a mutation pass
16
- * deletes in silence -- which is precisely what happened to the trigger-side remote refusal one slice ago,
17
- * so this one is a pure predicate over an explicit config from the start.
18
- *
19
- * TRUE when any blessed venue is remote, not merely when the DEFAULT is: a job that ran there has a
20
- * retained directory this host never had, and `pi-dispatch sandbox <jobId>` takes a job id rather than a
21
- * venue, so it cannot know which one it is being asked about until the directory is already missing.
22
- */
23
- export function sandboxUnreachableFrom(config) {
24
- const names = config?.backends ?? [config?.defaultBackend ?? DEFAULT_BACKEND];
25
- return names.some((n) => backendFor(n)?.remote !== false);
26
- }
4
+ import { basename } from "node:path";
5
+ import { SANDBOX_OPEN_GRACE_MS, launchSandbox, listRunningSandboxes, openSandbox, parsePublish, sandboxContainerName, sandboxLauncher, sandboxVenueOf, sandboxVenueRefusal, sandboxVenues } from "./sandbox.mjs";
6
+ import { listSandboxes, pinSandbox, sandboxDeadline } from "./sandbox-store.mjs";
27
7
 
28
8
  /**
29
9
  * `pi-dispatch sandbox` -- re-open a finished run's sandbox as an interactive shell
@@ -46,6 +26,10 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
46
26
  // The docker spawn used for this session's egress network, seamed like `launch` so the tests never
47
27
  // touch a daemon. Not used when PI_EGRESS=0.
48
28
  spawnNetwork = spawn,
29
+ // Issue #341: which uid the shell runs as. Seamed so the tests never ask a daemon.
30
+ resolveJobUser,
31
+ // Issue #452, gate round 2: the keeper check an egress-armed podman open asks first. Seamed like `resolveJobUser`.
32
+ keeperCheck,
49
33
  now = () => Date.now(),
50
34
  } = deps;
51
35
 
@@ -69,7 +53,21 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
69
53
  // A docker that cannot be reached costs a column, never the command: `listRunningSandboxes` throws so
70
54
  // the REAPER can tell "none" from "could not ask", and these callers only draw a marker. Asked only
71
55
  // where it is used, and never before the arguments are known good -- a typo should not shell out.
72
- const liveSandboxes = async () => new Set(await running().catch(() => []));
56
+ //
57
+ // EVERY runtime a sandbox can open on here (issue #429), each asked on its own and each degrading on its own: a
58
+ // podman that cannot be asked must not blank docker's column, and the reverse. `running({ bin })` is the same seam
59
+ // `openSandbox` asks with, so one fake answers both.
60
+ const liveSandboxes = async () => {
61
+ const live = new Set();
62
+ for (const venue of sandboxVenues(config.backends)) {
63
+ for (const id of await Promise.resolve()
64
+ .then(() => running({ bin: sandboxLauncher(venue).bin }))
65
+ .catch(() => [])) {
66
+ live.add(id);
67
+ }
68
+ }
69
+ return live;
70
+ };
73
71
 
74
72
  if (values.list) {
75
73
  return renderList({ config, live: await liveSandboxes(), out, now });
@@ -85,11 +83,6 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
85
83
  return fail(err, "`pi-dispatch sandbox` needs a terminal — it opens an interactive shell, so it cannot run from a pipe, a script without a TTY, or CI");
86
84
  }
87
85
 
88
- // `listRunningSandboxes` yields ids, already sanitized, so this compares like with like.
89
- if ((await liveSandboxes()).has(sanitizeJobId(jobId))) {
90
- return fail(err, `a sandbox for ${jobId} is already running — attach to it with \`docker attach ${sandboxContainerName(jobId)}\`, or exit it first`);
91
- }
92
-
93
86
  let publish;
94
87
  try {
95
88
  publish = parsePublish(values.publish ?? []);
@@ -97,74 +90,63 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
97
90
  return fail(err, error.message);
98
91
  }
99
92
 
100
- // #227. THE SANDBOX IS LOCAL-ONLY, and this refusal is what keeps that true rather than accidental.
101
- //
102
- // `buildSandboxRunArgs` is a SECOND container producer, outside the `runContainer` seam and hard-wired to
103
- // this host's docker CLI. It reopens a retained job directory: `manifest.workspace` is a path on THIS
104
- // machine, which for a job that ran in another venue either does not exist or exists and reproduces a
105
- // run from the wrong host, silently. `INT-SANDBOX-CONTRACT` is already "a SIBLING ... never an
106
- // amendment" of the container contract, and this is the clause that says which sibling.
107
- //
108
- // Refused BEFORE `resolveSandbox` reads anything, so a deployment that blesses a remote venue is told
109
- // what is wrong rather than handed a confusing missing-directory message.
110
- if (sandboxUnreachableFrom(config)) {
111
- return fail(
112
- err,
113
- `pi-dispatch sandbox opens a shell on THIS host's docker daemon against the job's retained directory, so it cannot reach a job that ran in a remote venue (PI_BACKENDS: ${config.backends.join(", ")}). Run it on the host that ran the job.`,
114
- );
115
- }
116
-
117
- const resolved = resolveSandbox({
93
+ // #227, #277. Everything from here to the shell is `openSandbox`, shared with the admin panel: every
94
+ // refusal (the per-job venue refusal included, which replaced a deployment-wide one this command used to
95
+ // apply on its own), the already-running refusal, this session's egress network and its teardown. The
96
+ // panel assembled the same session from parts and dropped the network; one function is what stops that.
97
+ // The run's venue's CLI, learned when the session is known to be openable, so the lines after the shell name the
98
+ // runtime that ran it (`podman attach`, not `docker attach`). Stays "docker" only for a failure before that point,
99
+ // which never reaches those lines.
100
+ let runtime = "docker";
101
+ // The container's name as `resolveSandbox` built it, off the run's directory (a run retained before #446's escape
102
+ // keeps its old one); the id's own spelling only before that point.
103
+ let containerName = sandboxContainerName(jobId);
104
+ const result = await openSandbox({
118
105
  jobId,
119
106
  sandboxDir: config.sandboxDir,
120
107
  retentionHours: config.sandboxRetentionHours,
121
108
  publish,
122
- });
123
- if (resolved.refused) return fail(err, resolved.message);
124
-
125
- // Pin BEFORE the shell, not after: the operator asked to keep this one, and a session that ends in a
126
- // crashed terminal or a closed laptop lid must not be the reason the pin never landed.
127
- if (values.pin) {
128
- const pinned = pinSandbox({ sandboxDir: config.sandboxDir, jobId, pinDays: config.sandboxPinDays, now });
129
- if (pinned.pinned) out(`pinned ${jobId} until ${pinned.keepUntil} (${config.sandboxPinDays}d)\n`);
130
- else err(`warning: could not pin ${jobId}: ${pinned.reason}\n`);
131
- }
132
-
133
- // REQ-EGRESS-ALLOWLIST: this session's own network, exactly like a job's, named off its own container
134
- // so the reaper's `pi-job-` filter never touches it -- a worker restart must not tear the network out
135
- // from under a shell an operator is sitting in.
136
- const network = config.egress ? networkNameFor(resolved.name) : null;
137
- const args = buildSandboxRunArgs({
138
- image: resolved.manifest.image,
139
- name: resolved.name,
140
- workspace: resolved.manifest.workspace,
141
- jobDir: resolved.manifest.dir,
142
- publish,
143
109
  // env-internal TERM: the operator's own terminal type, forwarded so the sandbox shell renders the
144
110
  // way their terminal does. Nothing a deployment declares.
145
111
  term: env.TERM,
146
112
  idleSeconds: config.sandboxIdleMinutes * 60,
147
- network,
148
- egressEnv: egressEnv({ proxy: config.egressProxy, armed: config.egress }),
113
+ egress: { armed: config.egress, proxy: config.egressProxy },
114
+ running,
115
+ launch,
116
+ spawnNetwork,
117
+ ...(resolveJobUser ? { resolveJobUser } : {}),
118
+ ...(keeperCheck ? { keeperCheck } : {}),
119
+ // Issue #452, gate round 4: a teardown the detach gate refused leaves the session's network, said here with its reason.
120
+ onNetworkKept: (network, reason) => err(`warning: the egress network ${network} was kept (${reason}): detaching the running proxy from it now could cut the proxy's route out on a rootless Podman 4.x whose rootless network keeper does not hold (issue #458); start the keeper, and the worker's retention sweep removes it\n`),
121
+ // Issue #429: which venues open here, and what a podman sandbox's observations must show, as the worker reads them.
122
+ blessed: config.backends,
123
+ backendFloor: config.backendFloor,
124
+ now,
125
+ // Pin BEFORE the shell, not after: the operator asked to keep this one, and a session that ends in a crashed
126
+ // terminal or a closed laptop lid must not be the reason the pin never landed. And since issue #446 before
127
+ // ANY runtime call, inside `openSandbox` straight after `resolveSandbox`, where a pin that fails refuses the open
128
+ // instead of printing a warning above a shell whose workspace the sweep may take.
129
+ pin: values.pin
130
+ ? () => {
131
+ const pinned = pinSandbox({ sandboxDir: config.sandboxDir, jobId, pinDays: config.sandboxPinDays, now });
132
+ if (pinned.pinned) out(`pinned ${jobId} until ${pinned.keepUntil} (${config.sandboxPinDays}d)\n`);
133
+ return pinned;
134
+ }
135
+ : null,
136
+ beforeLaunch: ({ resolved, runtime: cli }) => {
137
+ runtime = cli;
138
+ containerName = resolved.name;
139
+ out(`opening ${resolved.name} — image ${resolved.manifest.image}, workspace ${resolved.manifest.workspace}\n`);
140
+ out("no credentials are set in this container. exit the shell to dispose of it.\n");
141
+ if (publish.length > 0) out(`published: ${publish.filter((f) => f !== "-p").join(", ")}\n`);
142
+ },
149
143
  });
150
-
151
- out(`opening ${resolved.name} — image ${resolved.manifest.image}, workspace ${resolved.manifest.workspace}\n`);
152
- out("no credentials are set in this container. exit the shell to dispose of it.\n");
153
- if (publish.length > 0) out(`published: ${publish.filter((f) => f !== "-p").join(", ")}\n`);
154
-
155
- // No pre-spend gate here, deliberately: that is a MONEY gate and a sandbox spends nothing. A missing
156
- // proxy fails at `docker run` with docker's own message, in front of an operator at a terminal, which
157
- // is the one place a late failure is cheap.
158
- if (network && !(await createJobNetwork(spawnNetwork, { network, proxy: config.egressProxy }))) {
159
- return fail(err, `could not create the egress network ${network} -- is the proxy running? \`docker compose -f deploy/docker-compose.yml --profile egress up -d\``);
160
- }
161
- try {
162
- const { code, error } = await launch({ args });
163
- if (error) return fail(err, `could not start docker: ${error.message}`);
164
- return code ?? 0;
165
- } finally {
166
- if (network) await removeJobNetwork(spawnNetwork, { network, proxy: config.egressProxy });
167
- }
144
+ if (result.refused) return fail(err, result.message);
145
+ if (result.error) return fail(err, `could not start ${runtime}: ${result.error.message}`);
146
+ // Issue #446: a run lost from under a session after it started is said when the shell exits, never acted on then.
147
+ if (result.lost) err(`note: ${result.message}\n`);
148
+ if (result.detached) out(`detached: ${containerName} is still running with its egress network, which is left in place after it exits -- \`${runtime} attach ${containerName}\` to return\n`);
149
+ return result.code ?? 0;
168
150
  }
169
151
 
170
152
  /**
@@ -187,19 +169,42 @@ function renderList({ config, live, out, now }) {
187
169
  for (const row of rows) {
188
170
  const id = String(row.jobId ?? "?").padEnd(width);
189
171
  const kind = String(row.kind ?? "?").padEnd(8);
190
- const state = live.has(sanitizeJobId(row.jobId)) ? "RUNNING" : remaining(row, config.sandboxRetentionHours, now());
172
+ // A run this host cannot re-open is still listed (it is still retained and still swept), but not as
173
+ // time left on something re-openable (#277): the list answers "what can I open", and the venue says why not.
174
+ // A venue this command CAN open that this environment's PI_BACKENDS leaves out (issue #429) says which variable,
175
+ // because it is the one cause here an operator fixes in their own shell.
176
+ // Judged on the venue the refusal itself read (`sandboxVenueOf`): a manifest with no `backend` key is a `local`
177
+ // run from before attribution, so in a shell whose PI_BACKENDS leaves out `local` its cause is that variable,
178
+ // not a missing record.
179
+ const venue = sandboxVenueOf(row);
180
+ const state = sandboxVenueRefusal({ jobId: row.jobId, manifest: row, blessed: config.backends })
181
+ ? typeof venue === "string" && venue !== ""
182
+ ? sandboxLauncher(venue)
183
+ ? `not here (PI_BACKENDS lacks ${venue})`
184
+ : `not here (ran on ${venue})`
185
+ : "not openable (no venue recorded)"
186
+ : // The directory's own name, which is what the runtime reports (a run retained before the escape keeps its old one).
187
+ live.has(basename(row.dir))
188
+ ? "RUNNING"
189
+ : remaining(row, config.sandboxRetentionHours, now());
191
190
  out(`${id} ${kind} ${state}\n`);
192
191
  }
193
192
  return 0;
194
193
  }
195
194
 
196
- /** How long this one has left, from the manifest's own timestamps -- never from mtime, which a live sandbox moves. */
195
+ /**
196
+ * How long this one has left, from the manifest's own timestamps -- never from mtime, which a live sandbox moves.
197
+ * Through `sandboxDeadline` (issue #446), the sweep's own order, so a run the worker retained with a shorter window
198
+ * than this shell's PI_SANDBOX_RETENTION_HOURS shows the worker's deadline, which is the one that deletes it.
199
+ */
197
200
  function remaining(row, retentionHours, at) {
198
- const keepUntil = Date.parse(row.keepUntil ?? "");
199
- if (Number.isFinite(keepUntil)) return `pinned, ${humanise(keepUntil - at)} left`;
200
- const createdAt = Date.parse(row.createdAt ?? "");
201
- if (!Number.isFinite(createdAt)) return "expired";
202
- return `${humanise(createdAt + retentionHours * 3600000 - at)} left`;
201
+ const { until, source } = sandboxDeadline(row, retentionHours);
202
+ if (until === null) return "expired";
203
+ // Said, not counted down to "0h" (gate round 2): past the deadline, or inside the opener's grace, a plain open is
204
+ // refused, and the list is where an operator decides what to type.
205
+ if (until <= at) return "past its window (open with --pin)";
206
+ if (until - at <= SANDBOX_OPEN_GRACE_MS) return "within the grace (open with --pin)";
207
+ return source === "pin" ? `pinned, ${humanise(until - at)} left` : `${humanise(until - at)} left`;
203
208
  }
204
209
 
205
210
  function humanise(ms) {