@edgehero/pi-dispatch 0.3.0 → 1.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.
- package/.env.example +15 -0
- package/deploy/docker-compose.yml +59 -0
- package/deploy/egress-proxy.conf +36 -0
- package/deploy/receiver.service +11 -0
- package/deploy/worker-env-wrapper.cmd +33 -4
- package/deploy/worker-env-wrapper.sh +38 -2
- package/package.json +1 -1
- package/src/azure-prompt.mjs +57 -9
- package/src/config.mjs +117 -6
- package/src/docker-run.mjs +16 -1
- package/src/doctor.mjs +503 -13
- package/src/egress.mjs +221 -0
- package/src/env-allowlist.mjs +32 -1
- package/src/forgejo-prompt.mjs +65 -11
- package/src/get-token.mjs +5 -3
- package/src/github-prompt.mjs +11 -2
- package/src/gitlab-prompt.mjs +65 -11
- package/src/image-preflight.mjs +9 -0
- package/src/init.mjs +30 -0
- package/src/outbox.mjs +14 -0
- package/src/packages.mjs +89 -3
- package/src/prepare-github.mjs +18 -5
- package/src/prepare.mjs +12 -3
- package/src/processor.mjs +60 -0
- package/src/queue.mjs +25 -5
- package/src/run-container.mjs +39 -1
- package/src/run-history.mjs +1 -1
- package/src/sandbox-cli.mjs +24 -3
- package/src/sandbox.mjs +14 -1
- package/src/schedules.mjs +7 -1
- package/src/service.mjs +289 -25
- package/src/start.mjs +26 -0
- package/src/triggers.mjs +118 -25
- package/src/up.mjs +58 -0
package/src/triggers.mjs
CHANGED
|
@@ -191,13 +191,20 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
191
191
|
throw configError(`cron trigger "${id}": on.pattern must have 5 or 6 space-separated fields, got ${fieldCount}: ${path}`);
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
// FIRST among the run checks (all four normalizers do this), so a command-only entry is never told to
|
|
195
|
+
// add the flow it deliberately does not have, and a flow+command entry gets the exclusion message
|
|
196
|
+
// rather than whichever single-field check happens to run first.
|
|
197
|
+
const command = validateCommand(run, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
198
|
+
|
|
194
199
|
if (!isNonEmptyString(run.folder)) {
|
|
195
200
|
throw configError(`cron trigger "${id}": run.folder must be a non-empty string: ${path}`);
|
|
196
201
|
}
|
|
197
|
-
if (!isNonEmptyString(run.flow)) {
|
|
198
|
-
throw configError(`cron trigger "${id}": run.flow must be a non-empty string: ${path}`);
|
|
202
|
+
if (command === undefined && !isNonEmptyString(run.flow)) {
|
|
203
|
+
throw configError(`cron trigger "${id}": run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
199
204
|
}
|
|
200
|
-
|
|
205
|
+
// Gated on the flow path only: a command job's prompt IS the command line, and validateCommand has
|
|
206
|
+
// already refused any run.task written beside one.
|
|
207
|
+
if (command === undefined && !isNonEmptyString(run.task)) {
|
|
201
208
|
throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
|
|
202
209
|
}
|
|
203
210
|
|
|
@@ -229,7 +236,7 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
229
236
|
// freeze today's default into every stored repeatable.
|
|
230
237
|
return {
|
|
231
238
|
on: { type: "cron", id, pattern },
|
|
232
|
-
run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume, ...(skillsDir !== undefined && { skillsDir }) },
|
|
239
|
+
run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }) },
|
|
233
240
|
};
|
|
234
241
|
}
|
|
235
242
|
|
|
@@ -271,8 +278,9 @@ function validatePackagesFlag(run, at, path) {
|
|
|
271
278
|
* had. `validateReplicas` states the argument in one line and it applies verbatim here: a field accepted
|
|
272
279
|
* where it does nothing is how an operator comes to trust one that does nothing.
|
|
273
280
|
*
|
|
274
|
-
* "NOT YET COVERED", not impossible --
|
|
275
|
-
*
|
|
281
|
+
* "NOT YET COVERED", not impossible -- a distinction this file keeps because the two are different facts
|
|
282
|
+
* and an operator planning work needs the right one. `run.replicas` carried the same wording for the same
|
|
283
|
+
* reason until #187 closed its gap; this one is still open, which is why the phrasing outlived it. The local key already exists and is
|
|
276
284
|
* the strongest key in this feature: session-key.mjs keys a cron job on its scheduler id, which is
|
|
277
285
|
* operator-authored, unique across the file, stable across fires, and chosen by nobody untrusted. Nothing
|
|
278
286
|
* reaches it. Wiring `resolveSession` into the local path is a feature, and this line is what stops the
|
|
@@ -420,8 +428,9 @@ const INSTRUCTIONS_MAX = 2000;
|
|
|
420
428
|
* `run.instructions` (issue #60): one line of operator standing text, rendered into the USER prompt's
|
|
421
429
|
* envelope above the fenced data region.
|
|
422
430
|
*
|
|
423
|
-
* REFUSED on cron, and it is a DIFFERENT refusal from
|
|
424
|
-
* an operator-authored free-text field landing in the same
|
|
431
|
+
* REFUSED on cron, and it is a DIFFERENT refusal from the "not yet covered" shape (run.resume's, since
|
|
432
|
+
* #187 retired run.replicas'): cron already has an operator-authored free-text field landing in the same
|
|
433
|
+
* region of the same file. A local job's prompt
|
|
425
434
|
* is `flow hint + pointer + run.task` with no envelope, no data heading and no fence (prepare.mjs), so
|
|
426
435
|
* there is no "standing" region distinct from the task for a second field to occupy. Two fields writing
|
|
427
436
|
* one region with an undefined combination order is worse than a field that does nothing, because both
|
|
@@ -450,6 +459,85 @@ function validateInstructions(run, at, path, { cron = false } = {}) {
|
|
|
450
459
|
return text;
|
|
451
460
|
}
|
|
452
461
|
|
|
462
|
+
/**
|
|
463
|
+
* `run.command` (issue #189): dispatch a REGISTERED pi extension command headlessly, instead of a flow.
|
|
464
|
+
* The runner half is already merged: it exports the name via PI_COMMAND, refuses pre-spend when
|
|
465
|
+
* `getCommand` does not know it, and prompts with the exact `/name args` line and nothing else, so the
|
|
466
|
+
* arguments reach the command handler precisely as written here. Shared by all four normalizers so both
|
|
467
|
+
* services refuse the same file identically; the runner re-validates at the paid boundary regardless.
|
|
468
|
+
*
|
|
469
|
+
* EXACTLY ONE of run.flow / run.command, and the exclusion is checked BEFORE every normalizer's
|
|
470
|
+
* flow-required check on purpose: the two mistakes need their own messages. A flow+command entry no
|
|
471
|
+
* longer says which one runs and must hear that, not whichever single-field complaint fires first; a
|
|
472
|
+
* command-only entry must never be told to add the flow it deliberately does not have.
|
|
473
|
+
*
|
|
474
|
+
* The value rules each refuse something specific:
|
|
475
|
+
* - no leading "/": the runner PREPENDS the slash when it builds the prompt, so a written one would
|
|
476
|
+
* dispatch "//name" -- a command no registry holds, refused only after review already passed it.
|
|
477
|
+
* - surrounding whitespace is REFUSED, never trimmed (validateImageRef's rule): the arguments pass to
|
|
478
|
+
* the handler verbatim, so a silent trim is the reviewed file disagreeing with what runs.
|
|
479
|
+
* - no control characters. A newline would smuggle a SECOND line into what the operator reviewed as
|
|
480
|
+
* one command line, and the whole class is refused rather than the newline alone because every
|
|
481
|
+
* member is invisible in review, which is the hazard. The regex is spelled in \u escapes for the
|
|
482
|
+
* same reason: a literal ESC in this source would be exactly the unreviewable byte it refuses.
|
|
483
|
+
*
|
|
484
|
+
* Cross-field refusals, validateReplicas' posture (a field accepted where it does nothing is one an
|
|
485
|
+
* operator sets and then trusts):
|
|
486
|
+
* - run.task on cron: a command job's prompt IS the `/name args` line, so there is no task text for
|
|
487
|
+
* the runner to render -- two prompts written for one job, with only one ever sent.
|
|
488
|
+
* - run.instructions on the webhook kinds: instructions land in the prompt ENVELOPE, which a command
|
|
489
|
+
* job bypasses entirely, so nothing would render them. (Cron refuses instructions already, with its
|
|
490
|
+
* own run.task message, and that refusal stays the one a cron entry gets.)
|
|
491
|
+
* - run.resume: true on any kind: what a resumed session should do with a re-dispatched command is
|
|
492
|
+
* UNDESIGNED -- "not yet covered", validateResumeFlag's own vocabulary, because it is a gap to
|
|
493
|
+
* close and not a limit. Only `true` is refused; `false` is the documented default and refusing it
|
|
494
|
+
* would refuse an operator for writing down the behaviour they already have.
|
|
495
|
+
*
|
|
496
|
+
* Everything else stays orthogonal on purpose -- replicas, image, packages, skillsDir, repository,
|
|
497
|
+
* github. Those gate the CONTAINER a job runs in, and a command job runs in the same container a flow
|
|
498
|
+
* job does.
|
|
499
|
+
*
|
|
500
|
+
* A COMMENT command trigger has no default flow, which leaves the receiver's `<phrase> <flow>` comment
|
|
501
|
+
* override with nothing to override. That token is made inert by the receiver's FILTER, not refused
|
|
502
|
+
* here: the override lives in adversarial comment text, which this file-shape validator never sees.
|
|
503
|
+
*
|
|
504
|
+
* `at` is the caller's message prefix, `onType` selects the cross-field set. Returns the command,
|
|
505
|
+
* undefined when absent, and the callers spread it conditionally: a flow trigger must not grow the key
|
|
506
|
+
* at all, so an unflagged file normalizes byte-identically to today's (deepEqual pins depend on it).
|
|
507
|
+
*/
|
|
508
|
+
function validateCommand(run, at, path, { onType }) {
|
|
509
|
+
const raw = run.command;
|
|
510
|
+
if (run.flow !== undefined && raw !== undefined) {
|
|
511
|
+
throw configError(`${at}: exactly one of run.flow or run.command must be set -- a trigger dispatches either a flow or a registered command, and with both present the file does not say which one runs: ${path}`);
|
|
512
|
+
}
|
|
513
|
+
if (raw === undefined) return undefined;
|
|
514
|
+
if (!isNonEmptyString(raw)) {
|
|
515
|
+
throw configError(`${at}: run.command must be a non-empty string -- the registered command name, optionally followed by its arguments: ${path}`);
|
|
516
|
+
}
|
|
517
|
+
if (raw !== raw.trim()) {
|
|
518
|
+
throw configError(`${at}: run.command must not have leading or trailing whitespace -- the arguments reach the command handler verbatim, so trimming here would make the reviewed file disagree with what runs (got ${JSON.stringify(raw)}): ${path}`);
|
|
519
|
+
}
|
|
520
|
+
if (raw.startsWith("/")) {
|
|
521
|
+
throw configError(`${at}: run.command must not start with "/" -- the runner prepends the slash when it builds the /name args prompt, so a written one would dispatch "//name" (got ${JSON.stringify(raw)}): ${path}`);
|
|
522
|
+
}
|
|
523
|
+
// The class is the RUNNER's (parseCommand, image/runner/src/config.mjs), DEL included: the two
|
|
524
|
+
// must refuse identically, or a value that loads here refuses in-container with the budget
|
|
525
|
+
// slot already burned -- the drift INT-TRIGGERS-FILE-CONTRACT promises cannot happen.
|
|
526
|
+
if (/[\u0000-\u001F\u007F]/.test(raw)) {
|
|
527
|
+
throw configError(`${at}: run.command must not contain control characters -- a newline would smuggle a second line into what the operator reviewed as one command line (got ${JSON.stringify(raw)}): ${path}`);
|
|
528
|
+
}
|
|
529
|
+
if (onType === "cron" && run.task !== undefined) {
|
|
530
|
+
throw configError(`${at}: run.command and run.task cannot be combined -- a command job's prompt IS the /name args line, so there is no task text for the runner to render; put the arguments in run.command: ${path}`);
|
|
531
|
+
}
|
|
532
|
+
if (onType !== "cron" && run.instructions !== undefined) {
|
|
533
|
+
throw configError(`${at}: run.command and run.instructions cannot be combined -- instructions render into the prompt envelope, and a command job's prompt is the exact /name args line with no envelope, so nothing would render them: ${path}`);
|
|
534
|
+
}
|
|
535
|
+
if (run.resume === true) {
|
|
536
|
+
throw configError(`${at}: combining run.command and run.resume is not yet covered -- what a resumed session should do with a re-dispatched command is undesigned, so this is a gap to close, not a limit: ${path}`);
|
|
537
|
+
}
|
|
538
|
+
return raw;
|
|
539
|
+
}
|
|
540
|
+
|
|
453
541
|
/**
|
|
454
542
|
* Validate an `{any, all, none}` label predicate. Selectors are validated as arrays of non-empty strings
|
|
455
543
|
* BEFORE the positive-selector count, because `.length` is truthy on a string too -- a string selector
|
|
@@ -511,12 +599,12 @@ function validateRepository(run, onType, at, path) {
|
|
|
511
599
|
*
|
|
512
600
|
* WHY EACH REFUSAL:
|
|
513
601
|
* - a LOCAL (cron) trigger: its `/workspace` IS the operator's folder, bind-mounted read-write and edited
|
|
514
|
-
* in place, so two replicas would stomp each other's working tree with no gate and no undo. A
|
|
602
|
+
* in place, so two replicas would stomp each other's working tree with no gate and no undo. A forge
|
|
515
603
|
* job gets its own `mkdtemp`'d clone, which is the entire reason this is safe there and not here.
|
|
516
|
-
* Checked FIRST
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
604
|
+
* Checked FIRST, and since #187 that ordering carries the whole kind gate rather than merely picking
|
|
605
|
+
* which reason a cron trigger hears. Every forge mints its branch through the same `issueBranch`, so
|
|
606
|
+
* anything that is not `local` is now allowed; move this below the range check and a cron entry
|
|
607
|
+
* carrying `replicas: 2` would be ACCEPTED, not refused with a different message.
|
|
520
608
|
* - a non-integer, `< 2`, or `> REPLICAS_MAX`. `1` is REFUSED rather than accepted: a one-member replica
|
|
521
609
|
* set is a field that does nothing, and this validator's whole job is to make sure nothing does nothing.
|
|
522
610
|
* - `run.resume: true`. A resumed run continues ONE lineage; replicas exist to fork it. This is the
|
|
@@ -538,9 +626,6 @@ function validateReplicas(run, at, path) {
|
|
|
538
626
|
if (run.kind === "local") {
|
|
539
627
|
throw configError(`${at}: run.replicas is not available on a cron trigger -- a local job's /workspace IS the operator's folder, bind-mounted read-write, so two replicas would edit one working tree with no gate and no undo: ${path}`);
|
|
540
628
|
}
|
|
541
|
-
if (run.kind !== "github") {
|
|
542
|
-
throw configError(`${at}: run.replicas is not yet covered for ${run.kind} triggers (github only in this version); every forge mints its branch the same way, so this is a gap to close, not a limit: ${path}`);
|
|
543
|
-
}
|
|
544
629
|
if (!Number.isInteger(replicas) || replicas < 2 || replicas > REPLICAS_MAX) {
|
|
545
630
|
throw configError(`${at}: run.replicas must be an integer between 2 and ${REPLICAS_MAX} when present -- ${REPLICAS_MAX} is the ceiling because PI_CONCURRENCY defaults to 3, so a further replica would queue instead of racing, and 1 is refused because a one-member replica set is a flag that does nothing (got ${JSON.stringify(replicas)}): ${path}`);
|
|
546
631
|
}
|
|
@@ -553,8 +638,10 @@ function validateReplicas(run, at, path) {
|
|
|
553
638
|
function normalizeLabel(on, run, index, path) {
|
|
554
639
|
const at = `trigger at index ${index}`;
|
|
555
640
|
const predicate = validatePredicate(on, index, path, true);
|
|
556
|
-
|
|
557
|
-
|
|
641
|
+
// First among the run checks, before the flow-required check -- validateCommand says why.
|
|
642
|
+
const command = validateCommand(run, at, path, { onType: "label" });
|
|
643
|
+
if (command === undefined && !isNonEmptyString(run.flow)) {
|
|
644
|
+
throw configError(`${at}: label trigger run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
558
645
|
}
|
|
559
646
|
const packages = validatePackagesFlag(run, at, path);
|
|
560
647
|
const image = validateImageRef(run, at, path);
|
|
@@ -565,7 +652,7 @@ function normalizeLabel(on, run, index, path) {
|
|
|
565
652
|
const replicas = validateReplicas(run, at, path);
|
|
566
653
|
return {
|
|
567
654
|
on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
|
|
568
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
|
|
655
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
|
|
569
656
|
};
|
|
570
657
|
}
|
|
571
658
|
|
|
@@ -574,8 +661,12 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
574
661
|
if (!isNonEmptyString(on.phrase)) {
|
|
575
662
|
throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
|
|
576
663
|
}
|
|
577
|
-
|
|
578
|
-
|
|
664
|
+
// First among the run checks, before the flow-required check -- validateCommand says why. A command
|
|
665
|
+
// trigger has NO default flow for the `<phrase> <flow>` comment override to replace; making that
|
|
666
|
+
// token inert is the receiver filter's job, not a shape this validator can see.
|
|
667
|
+
const command = validateCommand(run, at, path, { onType: "comment" });
|
|
668
|
+
if (command === undefined && !isNonEmptyString(run.flow)) {
|
|
669
|
+
throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string (or use run.command): ${path}`);
|
|
579
670
|
}
|
|
580
671
|
// At most one comment trigger PER FORGE. The cap exists because the receiver holds one comment rule
|
|
581
672
|
// per forge and a second would be silently unreachable -- so it is a cap on ambiguity, not on count,
|
|
@@ -593,7 +684,7 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
593
684
|
const replicas = validateReplicas(run, at, path);
|
|
594
685
|
return {
|
|
595
686
|
on: { type: "comment", phrase: on.phrase },
|
|
596
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
|
|
687
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
|
|
597
688
|
};
|
|
598
689
|
}
|
|
599
690
|
|
|
@@ -634,8 +725,10 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
634
725
|
throw configError(`${at}: an azure pull_request trigger cannot carry a label predicate -- Azure DevOps attaches tags to work items, never to pull requests, so any/all/none could never match: ${path}`);
|
|
635
726
|
}
|
|
636
727
|
const predicate = validatePredicate(on, index, path, requirePositive);
|
|
637
|
-
|
|
638
|
-
|
|
728
|
+
// First among the run checks, before the flow-required check -- validateCommand says why.
|
|
729
|
+
const command = validateCommand(run, at, path, { onType: "pull_request" });
|
|
730
|
+
if (command === undefined && !isNonEmptyString(run.flow)) {
|
|
731
|
+
throw configError(`${at}: pull_request trigger run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
639
732
|
}
|
|
640
733
|
const packages = validatePackagesFlag(run, at, path);
|
|
641
734
|
const image = validateImageRef(run, at, path);
|
|
@@ -655,7 +748,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
655
748
|
all: predicate.all,
|
|
656
749
|
none: predicate.none,
|
|
657
750
|
},
|
|
658
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }) },
|
|
751
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }) },
|
|
659
752
|
};
|
|
660
753
|
}
|
|
661
754
|
|
package/src/up.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { spawn as nodeSpawn } from "node:child_process";
|
|
|
24
24
|
import { chmodSync, existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
25
25
|
import { connect as netConnect } from "node:net";
|
|
26
26
|
import { join } from "node:path";
|
|
27
|
+
import { egressArmed as egressArmedFn } from "./egress.mjs";
|
|
27
28
|
import { updateEnvFile } from "./env-file.mjs";
|
|
28
29
|
|
|
29
30
|
// The one image up may ever fetch, and the local name jobs run under. Literal on purpose (not
|
|
@@ -63,6 +64,31 @@ const VALKEY_RUN_ARGS = [
|
|
|
63
64
|
"yes",
|
|
64
65
|
];
|
|
65
66
|
|
|
67
|
+
// deploy/docker-compose.yml's `egress` profile, reproduced as one docker run (REQ-EGRESS-ALLOWLIST):
|
|
68
|
+
// same digest-pinned image, same two mounts, same explicit container name, same restart policy, on the
|
|
69
|
+
// same upstream network. Written out here for the same reason VALKEY_RUN_ARGS is -- an operator who runs
|
|
70
|
+
// `up` and one who runs compose must end up with the same component, and two ways of starting one thing
|
|
71
|
+
// is two places for it to drift.
|
|
72
|
+
//
|
|
73
|
+
// The per-job networks are NOT here: the worker creates one per job and attaches this container to it for
|
|
74
|
+
// the life of that run. This is only the proxy and its way out.
|
|
75
|
+
const EGRESS_NETWORK_ARGS = ["network", "create", "pi-dispatch-egress-out"];
|
|
76
|
+
const EGRESS_RUN_ARGS = [
|
|
77
|
+
"run",
|
|
78
|
+
"-d",
|
|
79
|
+
"--name",
|
|
80
|
+
"pi-dispatch-egress-proxy",
|
|
81
|
+
"--restart",
|
|
82
|
+
"unless-stopped",
|
|
83
|
+
"--network",
|
|
84
|
+
"pi-dispatch-egress-out",
|
|
85
|
+
"-v",
|
|
86
|
+
"./deploy/egress-proxy.conf:/etc/squid/squid.conf:ro",
|
|
87
|
+
"-v",
|
|
88
|
+
"./egress-allowlist.conf:/etc/pi-dispatch/allowlist.conf:ro",
|
|
89
|
+
"ubuntu/squid@sha256:6a097f68bae708cedbabd6188d68c7e2e7a38cedd05a176e1cc0ba29e3bbe029",
|
|
90
|
+
];
|
|
91
|
+
|
|
66
92
|
export async function runUp(argv = [], deps = {}) {
|
|
67
93
|
const {
|
|
68
94
|
env = process.env,
|
|
@@ -173,6 +199,38 @@ export async function runUp(argv = [], deps = {}) {
|
|
|
173
199
|
summary.push(["WEBHOOK_SECRET", "no .env here — skipped (set it wherever your env lives)"]);
|
|
174
200
|
}
|
|
175
201
|
|
|
202
|
+
// (e2) the egress policy's proxy, and ONLY when the operator has already armed it. up never invents
|
|
203
|
+
// operator policy -- the same doctrine that keeps it pulling this repo's own image and no other -- so a
|
|
204
|
+
// deployment that has not set PI_EGRESS hears nothing about this at all.
|
|
205
|
+
//
|
|
206
|
+
// AFTER init, deliberately: init has just scaffolded egress-allowlist.conf, and starting a proxy whose
|
|
207
|
+
// allowlist file does not exist gets a directory created by docker where a file belonged and a squid
|
|
208
|
+
// that fails confusingly. If the file is still missing, this step declines itself and says which file.
|
|
209
|
+
if (egressArmedFn(env)) {
|
|
210
|
+
if ((await runCmd(spawn, "docker", ["inspect", "--format={{.State.Running}}", "pi-dispatch-egress-proxy"])) === 0) {
|
|
211
|
+
out("\n✓ Egress proxy already present (pi-dispatch-egress-proxy)\n");
|
|
212
|
+
summary.push(["egress", "proxy already present — left untouched"]);
|
|
213
|
+
} else if (!fs.existsSync(join(cwd, "egress-allowlist.conf"))) {
|
|
214
|
+
out("\n✗ the egress policy is on but egress-allowlist.conf is not here — not starting a proxy with no allowlist\n");
|
|
215
|
+
summary.push(["egress", "skipped — no egress-allowlist.conf in this folder; run `pi-dispatch init` here, then `up` again"]);
|
|
216
|
+
} else if (
|
|
217
|
+
await consent("The egress policy is on (PI_EGRESS=0 opts out) but the allowlist proxy is not running. up would start it (same semantics as deploy/docker-compose.yml --profile egress):", [`docker ${EGRESS_NETWORK_ARGS.join(" ")}`, `docker ${quoteArgs(EGRESS_RUN_ARGS)}`], { yes, out, prompt })
|
|
218
|
+
) {
|
|
219
|
+
// The network may already exist from a previous run; that is not a failure, so its code is not
|
|
220
|
+
// checked. The proxy is what matters and it is checked.
|
|
221
|
+
await runStreamed(spawn, "docker", EGRESS_NETWORK_ARGS, out);
|
|
222
|
+
if ((await runStreamed(spawn, "docker", EGRESS_RUN_ARGS, out)) !== 0) {
|
|
223
|
+
out("✗ could not start the egress proxy — continuing; doctor below will re-check it\n");
|
|
224
|
+
summary.push(["egress", "start failed — every job refuses pre-spend until it is up (costs no budget, runs nothing)"]);
|
|
225
|
+
} else {
|
|
226
|
+
summary.push(["egress", "started pi-dispatch-egress-proxy on pi-dispatch-egress-out"]);
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
out("skipped — start it later with `docker compose -f deploy/docker-compose.yml --profile egress up -d`\n");
|
|
230
|
+
summary.push(["egress", "skipped (declined) — every job is refused pre-spend until the proxy is up (PI_EGRESS=0 opts out)"]);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
176
234
|
// (f) doctor — always, verbatim: up converges what it can, doctor is the judge of what remains
|
|
177
235
|
// (provider key, forge env, overlay …), and its verdict is up's exit code.
|
|
178
236
|
out("\ndoctor:\n");
|