@edgehero/pi-dispatch 4.3.1 → 5.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 +84 -3
- package/README.md +21 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +118 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +136 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +190 -29
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +325 -0
- package/src/console-client.mjs +848 -0
- package/src/console-devices.mjs +372 -0
- package/src/console-install.mjs +239 -0
- package/src/console-open.mjs +99 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1657 -196
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +96 -6
- package/src/env-file.mjs +6 -4
- package/src/envelope.mjs +5 -3
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-host.mjs +42 -5
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +33 -3
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +734 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +4 -2
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +12 -2
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1550 -173
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +264 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +36 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +780 -38
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +402 -53
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a job waits (issue #737): the closed vocabulary the worker writes into `defer:job:<id>` when it puts a job back
|
|
3
|
+
* on the delayed set, and the rules a reader holds each field to. A LEAF: it imports nothing but the BullMQ leaf below,
|
|
4
|
+
* so the admin console can import it (`@edgehero/pi-dispatch/defer-reasons`) without pulling the worker's dependencies
|
|
5
|
+
* into its bundle. The writer is `defer-write.mjs`; the reader is the admin's `readWaiting`.
|
|
6
|
+
*
|
|
7
|
+
* The record is the worker's own words for its own decision, written at the moment it defers, so a reader never has to
|
|
8
|
+
* guess a reason from a job's data (which it must not read: `INT-WAIT-PROFILES-CONTRACT`) or from its timing. A job
|
|
9
|
+
* with no record says so ("No reason was recorded"), and one whose record names a reason this console does not know
|
|
10
|
+
* says that; each is true, never a guess. Every value is judged here on the way in (`deferHsetArgs`) and on the way out
|
|
11
|
+
* (`parseDefer`), because the hash lives in a shared Valkey that anything with its URL can write.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The hash's key prefix; the job's id follows it. */
|
|
15
|
+
export const DEFER_KEY = "defer:job:";
|
|
16
|
+
|
|
17
|
+
/** The defer record's key for a job id. */
|
|
18
|
+
export const deferKey = (jobId) => `${DEFER_KEY}${jobId}`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The buckets a console groups waiting jobs by, in the order it shows them. The last four are the reader's own and no
|
|
22
|
+
* reason in `DEFER_WHYS` is in them: `paused` holds a job in a paused queue, `due` a delayed job whose due instant has
|
|
23
|
+
* passed with no worker taking it (judged from the delayed set and the read's clock, whatever its record says),
|
|
24
|
+
* `unknown` a job whose record names a reason this console does not know (it has the reason charset but is not in
|
|
25
|
+
* `DEFER_WHYS`: most often a newer worker's), and `none` a job with no record, or with one whose reason fails the
|
|
26
|
+
* charset (no reason at all). Each title is true of every job in its bucket, which is why a reason this console cannot
|
|
27
|
+
* explain has a bucket of its own rather than sharing `none`'s "No reason was recorded".
|
|
28
|
+
*/
|
|
29
|
+
export const DEFER_BUCKETS = Object.freeze(["slot", "run", "check", "retry", "pause", "ready", "paused", "due", "unknown", "none"]);
|
|
30
|
+
|
|
31
|
+
/** Each bucket's words: a waitlist's title and its short form (a chip, a row's tag). */
|
|
32
|
+
export const DEFER_BUCKET_WORDS = Object.freeze({
|
|
33
|
+
slot: Object.freeze({ title: "Waiting for a free slot", short: "Free slot" }),
|
|
34
|
+
run: Object.freeze({ title: "Waiting for another run to finish", short: "Another run" }),
|
|
35
|
+
check: Object.freeze({ title: "Waiting on a time or a check", short: "Time or check" }),
|
|
36
|
+
retry: Object.freeze({ title: "Retry backoff", short: "Retry backoff" }),
|
|
37
|
+
pause: Object.freeze({ title: "In a pause window", short: "Pause window" }),
|
|
38
|
+
ready: Object.freeze({ title: "Waiting for its host to get ready", short: "Host getting ready" }),
|
|
39
|
+
paused: Object.freeze({ title: "In a paused queue", short: "Queue paused" }),
|
|
40
|
+
due: Object.freeze({ title: "Due, and no worker has taken it", short: "Due, not taken" }),
|
|
41
|
+
unknown: Object.freeze({ title: "A reason this console does not know", short: "Unknown reason" }),
|
|
42
|
+
none: Object.freeze({ title: "No reason was recorded", short: "No reason recorded" }),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The `due` bucket's line, whose `{due}` is the job's due instant (the delayed set's, drawn as a `<time>`). Not a reason
|
|
47
|
+
* in `DEFER_WHYS`: no worker records it, since the job is overdue exactly because no worker has looked at it since.
|
|
48
|
+
*/
|
|
49
|
+
export const DEFER_DUE_SENTENCE = "Due since {due}; no worker has taken it";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Every reason the worker can record: its bucket and the sentence a console shows, whose `{placeholders}` the
|
|
53
|
+
* console fills from the record's own fields (escaped; times drawn as `<time>`). A reason not in this table is
|
|
54
|
+
* written by nobody: `deferHsetArgs` throws on one (a programming error), and `parseDefer` reads one as unknown.
|
|
55
|
+
*/
|
|
56
|
+
export const DEFER_WHYS = Object.freeze({
|
|
57
|
+
canary: Object.freeze({ bucket: "run", sentence: "Waiting for the operator model canary to finish" }),
|
|
58
|
+
"pause-window": Object.freeze({ bucket: "pause", sentence: "In a pause window until {until}" }),
|
|
59
|
+
"size-never-fits": Object.freeze({ bucket: "slot", sentence: "Its size fits no slot on {host}; another host may take it" }),
|
|
60
|
+
"host-slot": Object.freeze({ bucket: "slot", sentence: "{host} has no free job slot" }),
|
|
61
|
+
"scope-folder": Object.freeze({ bucket: "slot", sentence: "Its folder is busy with another run" }),
|
|
62
|
+
"scope-repo": Object.freeze({ bucket: "slot", sentence: "Its repository's concurrent limit is full" }),
|
|
63
|
+
"scope-project": Object.freeze({ bucket: "slot", sentence: "Its project's concurrent limit is full" }),
|
|
64
|
+
endpoint: Object.freeze({ bucket: "slot", sentence: "Every slot of model endpoint {endpoint} is taken" }),
|
|
65
|
+
"subscription-quota": Object.freeze({ bucket: "check", sentence: "The subscription account is exhausted; waits for newer evidence, a known reset or an operator trial. Asks again at {until}" }),
|
|
66
|
+
"subscription-owner": Object.freeze({ bucket: "ready", sentence: "Waits for the host that owns its subscription connection" }),
|
|
67
|
+
"subscription-auth": Object.freeze({ bucket: "check", sentence: "Waits until the subscription credential can be renewed at {until}" }),
|
|
68
|
+
"model-cooldown": Object.freeze({ bucket: "check", sentence: "No reviewed model is available; a model is cooling down. Asks again at {until}" }),
|
|
69
|
+
"model-window": Object.freeze({ bucket: "check", sentence: "No reviewed model is available; a model's dollar window is full. Asks again at {until}" }),
|
|
70
|
+
"host-budget": Object.freeze({ bucket: "slot", sentence: "{host}'s budget has no room for it yet" }),
|
|
71
|
+
"bundle-profile": Object.freeze({ bucket: "slot", sentence: "Waits for a host that serves its secret bundle profile" }),
|
|
72
|
+
chained: Object.freeze({ bucket: "run", sentence: "Chained after {holder}; starts when its folder is free" }),
|
|
73
|
+
"recovery-wait": Object.freeze({ bucket: "run", sentence: "Recovers run {holder}, whose attempt is still running" }),
|
|
74
|
+
"wait-after": Object.freeze({ bucket: "check", sentence: "Held until {until}" }),
|
|
75
|
+
"wait-hold": Object.freeze({ bucket: "check", sentence: "Held on its wait check; asks again at {until}" }),
|
|
76
|
+
"wait-throttled": Object.freeze({ bucket: "check", sentence: "Its wait check waits for a free check slot" }),
|
|
77
|
+
"wait-supersede": Object.freeze({ bucket: "check", sentence: "Asks again whether the delivery holding its target still waits" }),
|
|
78
|
+
"wait-aborted": Object.freeze({ bucket: "check", sentence: "Its wait check was stopped by a restart or a cancel; asks again soon" }),
|
|
79
|
+
"wait-unclear": Object.freeze({ bucket: "check", sentence: "Its wait check answered in a way the worker cannot read; asks again at {until}" }),
|
|
80
|
+
"podman-restart": Object.freeze({ bucket: "ready", sentence: "Waits for rootful Podman's service on {host} to restart" }),
|
|
81
|
+
"netns-keeper": Object.freeze({ bucket: "ready", sentence: "Waits for the network keeper on {host} to settle" }),
|
|
82
|
+
"newer-release": Object.freeze({ bucket: "ready", sentence: "Queued or configured by a newer pi-dispatch: waits for this host's upgrade" }),
|
|
83
|
+
retry: Object.freeze({ bucket: "retry", sentence: "Attempt {attempt} of {attempts} at {until}" }),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** The hash's fields, in the order a reader's HMGET asks for them. `n` is the writer's own nonce, never shown. */
|
|
87
|
+
export const DEFER_FIELDS = Object.freeze(["why", "at", "until", "project", "host", "holder", "endpoint", "budgetWhy", "trigger", "attempt", "attempts", "n"]);
|
|
88
|
+
|
|
89
|
+
/** A reason's charset: what `parseDefer` takes as a reason word at all (a word outside the table reads as unknown). */
|
|
90
|
+
export const DEFER_WHY_RE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
91
|
+
/** The writer's nonce: 16 hex characters, so a compare-and-delete can tell its own record from another worker's. */
|
|
92
|
+
export const DEFER_N_RE = /^[0-9a-f]{16}$/;
|
|
93
|
+
/** A job id the record may name (`holder`). Pinned equal to the admin reader's `RUN_ID_RE` by a test. */
|
|
94
|
+
export const DEFER_ID_RE = /^[A-Za-z0-9._:-]{1,200}$/;
|
|
95
|
+
/** A project id. A copy of `project-id.mjs`'s `PROJECT_ID_RE`, pinned equal to it by a test (this module imports nothing). */
|
|
96
|
+
export const DEFER_PROJECT_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
97
|
+
/** A host's name. A copy of `worker-name.mjs`'s `WORKER_NAME_RE`, pinned equal to it by a test. */
|
|
98
|
+
export const DEFER_HOST_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
99
|
+
/** A model endpoint's id. A copy of `model-endpoints.mjs`'s `MODEL_ENDPOINT_ID_RE`, pinned equal to it by a test. */
|
|
100
|
+
export const DEFER_ENDPOINT_RE = /^[a-z0-9-]{1,32}$/;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The last instant a UTC date-time can be written with a four-digit year: 9999-12-31T23:59:59.999Z. Every instant this
|
|
104
|
+
* leaf passes on (`at`, `until`, a due time) is at most this, so a console can always draw it. NOT the latest instant a
|
|
105
|
+
* JavaScript Date holds (8.64e15 ms, in the year 275760): `toISOString` writes a year past 9999 as six digits with a
|
|
106
|
+
* sign (`+275760-09-13T00:00:00.000Z`), and a console that draws the first sixteen characters as `YYYY-MM-DD HH:MM`
|
|
107
|
+
* would show a date that is not the instant's.
|
|
108
|
+
*/
|
|
109
|
+
export const DEFER_TIME_MAX_MS = 253402300799999;
|
|
110
|
+
|
|
111
|
+
/** How long a record outlives the job's due time, so a record is still there while the job is promoted and picked up. */
|
|
112
|
+
export const DEFER_TTL_SLACK_MS = 60_000;
|
|
113
|
+
/** A record's lifetime: until the job is due, plus the slack, and never less than the slack. */
|
|
114
|
+
export const deferTtlMs = (untilMs, nowMs) => Math.max(DEFER_TTL_SLACK_MS, untilMs - nowMs + DEFER_TTL_SLACK_MS);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The delayed set's score and a scheduler's next run are BullMQ's facts, so they live in `bullmq-internals.mjs` (issue
|
|
118
|
+
* #761) and are re-exported here, where they were first published: a reader of this subpath keeps every name.
|
|
119
|
+
*/
|
|
120
|
+
export { DELAYED_SCORE_SHIFT, dueOfScore, isSchedulerNext } from "./bullmq-internals.mjs";
|
|
121
|
+
|
|
122
|
+
/** A decimal safe integer, from a number or its string form, or null. */
|
|
123
|
+
function intOf(value) {
|
|
124
|
+
if (typeof value === "number") return Number.isSafeInteger(value) ? value : null;
|
|
125
|
+
if (typeof value !== "string" || !/^(?:0|[1-9][0-9]{0,15})$/.test(value)) return null;
|
|
126
|
+
const n = Number(value);
|
|
127
|
+
return Number.isSafeInteger(n) ? n : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** An integer within [lo, hi], or null. */
|
|
131
|
+
function intIn(value, lo, hi) {
|
|
132
|
+
const n = intOf(value);
|
|
133
|
+
return n !== null && n >= lo && n <= hi ? n : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A string that passes `re`, or null. */
|
|
137
|
+
const textOf = (value, re) => (typeof value === "string" && re.test(value) ? value : null);
|
|
138
|
+
|
|
139
|
+
/** Each field's rule: the value as the record holds it (a number or a string), or null when it fails. */
|
|
140
|
+
const FIELD_RULES = Object.freeze({
|
|
141
|
+
why: (v) => (typeof v === "string" && Object.hasOwn(DEFER_WHYS, v) ? v : null),
|
|
142
|
+
at: (v) => intIn(v, 0, DEFER_TIME_MAX_MS),
|
|
143
|
+
until: (v) => intIn(v, 0, DEFER_TIME_MAX_MS),
|
|
144
|
+
project: (v) => textOf(v, DEFER_PROJECT_RE),
|
|
145
|
+
host: (v) => textOf(v, DEFER_HOST_RE),
|
|
146
|
+
holder: (v) => textOf(v, DEFER_ID_RE),
|
|
147
|
+
endpoint: (v) => textOf(v, DEFER_ENDPOINT_RE),
|
|
148
|
+
budgetWhy: (v) => textOf(v, DEFER_WHY_RE),
|
|
149
|
+
trigger: (v) => intIn(v, 0, 9999),
|
|
150
|
+
attempt: (v) => intIn(v, 1, 1000),
|
|
151
|
+
attempts: (v) => intIn(v, 1, 1000),
|
|
152
|
+
n: (v) => textOf(v, DEFER_N_RE),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The HSET arguments for a record, `[field, value, ...]`, each value judged by its field's rule and left out when it
|
|
157
|
+
* fails (a record with a missing field is still worth writing; one with a wrong field is not worth trusting). A
|
|
158
|
+
* reason not in `DEFER_WHYS` throws: every call site names its reason by a literal, so an unknown one is a bug.
|
|
159
|
+
*/
|
|
160
|
+
export function deferHsetArgs(rec) {
|
|
161
|
+
const why = rec?.why;
|
|
162
|
+
if (typeof why !== "string" || !Object.hasOwn(DEFER_WHYS, why)) throw new RangeError(`defer-reasons: unknown reason ${JSON.stringify(why)}`);
|
|
163
|
+
const args = [];
|
|
164
|
+
for (const field of DEFER_FIELDS) {
|
|
165
|
+
const value = FIELD_RULES[field](rec[field]);
|
|
166
|
+
if (value !== null) args.push(field, String(value));
|
|
167
|
+
}
|
|
168
|
+
return args;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A record from an HMGET of `DEFER_FIELDS`, in that order: null when there is no `why` (no record, or none worth
|
|
173
|
+
* reading); otherwise every field, each null when it is absent or fails its rule. A `why` with the reason charset but
|
|
174
|
+
* not in the table is read as unknown (`known` false, bucket `unknown`): a newer worker's reason an older console does
|
|
175
|
+
* not know. A `why` outside the charset is no reason at all (`why` null, bucket `none`). The nonce `n` is the writer's
|
|
176
|
+
* and is never returned.
|
|
177
|
+
*/
|
|
178
|
+
export function parseDefer(values) {
|
|
179
|
+
if (!Array.isArray(values)) return null;
|
|
180
|
+
const raw = values[0];
|
|
181
|
+
if (raw === null || raw === undefined || raw === "") return null;
|
|
182
|
+
const why = typeof raw === "string" && DEFER_WHY_RE.test(raw) ? raw : null;
|
|
183
|
+
const known = why !== null && Object.hasOwn(DEFER_WHYS, why);
|
|
184
|
+
const at = (i) => values[i];
|
|
185
|
+
return {
|
|
186
|
+
why,
|
|
187
|
+
known,
|
|
188
|
+
bucket: known ? DEFER_WHYS[why].bucket : why !== null ? "unknown" : "none",
|
|
189
|
+
at: FIELD_RULES.at(at(1)),
|
|
190
|
+
until: FIELD_RULES.until(at(2)),
|
|
191
|
+
project: FIELD_RULES.project(at(3)),
|
|
192
|
+
host: FIELD_RULES.host(at(4)),
|
|
193
|
+
holder: FIELD_RULES.holder(at(5)),
|
|
194
|
+
endpoint: FIELD_RULES.endpoint(at(6)),
|
|
195
|
+
budgetWhy: FIELD_RULES.budgetWhy(at(7)),
|
|
196
|
+
trigger: FIELD_RULES.trigger(at(8)),
|
|
197
|
+
attempt: FIELD_RULES.attempt(at(9)),
|
|
198
|
+
attempts: FIELD_RULES.attempts(at(10)),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worker's half of "why does this job wait" (issue #737): every gate that puts a job back on the delayed set
|
|
3
|
+
* writes its reason into `defer:job:<id>` (`defer-reasons.mjs` holds the vocabulary and the field rules) just before
|
|
4
|
+
* BullMQ's move, through ONE function, `deferTo`, so no deferral can move a job without saying why. A retry after an
|
|
5
|
+
* infrastructure failure, which BullMQ delays by itself after the processor throws, is noted by `noteRetry` before
|
|
6
|
+
* the throw. Worker-only, and free of bullmq itself: the processor hands it BullMQ's `DelayedError` class (index.mjs
|
|
7
|
+
* imports bullmq; `valkey-clients.test.mjs` keeps every other module from naming that library), so the throw is the
|
|
8
|
+
* very class the processor's own catch tells a deferral apart by. The admin console never loads it (it reads the leaf).
|
|
9
|
+
*
|
|
10
|
+
* THE RECORD IS ADVISORY, so it FAILS OPEN and is BOUNDED. A deferral never fails or changes for its reason, and waits
|
|
11
|
+
* for it at most `DEFER_WRITE_TIMEOUT_MS` (2 s): a write that fails or takes longer is logged once per episode and the
|
|
12
|
+
* job is moved all the same, and a logger that throws is swallowed rather than rejecting the deferral. The bound is not
|
|
13
|
+
* decoration: the worker's client queues commands during an outage rather than rejecting them
|
|
14
|
+
* (`maxRetriesPerRequest: null`, the host registry's reason), so an unbounded write would hold a gate's deferral for
|
|
15
|
+
* the whole outage. The move itself is BullMQ's, exactly as it was before this file.
|
|
16
|
+
*
|
|
17
|
+
* WRITE, THEN MOVE. A reader starts from the delayed set and looks up the records of the ids it finds there, so a
|
|
18
|
+
* record written before the move is in place the moment the job is delayed, and one for a job that is still active
|
|
19
|
+
* is never read. The write is one MULTI (`DEL`, `HSET`, `PEXPIRE`), so a reader never sees this deferral's fields
|
|
20
|
+
* mixed with an earlier one's, and every record carries a TTL (`deferTtlMs`: the due time plus a minute's slack,
|
|
21
|
+
* so the record outlives the promotion and the pickup that follows it).
|
|
22
|
+
*
|
|
23
|
+
* A MOVE THAT REJECTS (a lost lock, Valkey gone) leaves a record for a job that is not delayed, so its own record is
|
|
24
|
+
* removed by a compare-and-delete on the writer's nonce (`n`): a plain DEL could remove the record another worker
|
|
25
|
+
* wrote for a later deferral of the same job id. The rejection is rethrown unchanged, so it reaches BullMQ's
|
|
26
|
+
* failed-attempt handling exactly as a raw `moveToDelayed` rejection always has.
|
|
27
|
+
*
|
|
28
|
+
* A TIMED-OUT WRITE MAY STILL LAND. The bound stops the waiting, not the command: a queued MULTI runs when the
|
|
29
|
+
* connection comes back. That is why a timed-out write still returns its nonce: the compare-and-delete is sent on
|
|
30
|
+
* the same connection after it, so it runs after the MULTI if that lands at all. A record that lands for a job that
|
|
31
|
+
* then ran is not read while the job is not delayed, and expires with its TTL.
|
|
32
|
+
*
|
|
33
|
+
* WHAT A RECORD CANNOT PROMISE. A write that failed leaves the previous deferral's record, until it expires, beside a
|
|
34
|
+
* job now delayed for another reason; and a job BullMQ delays by itself (a move's rejection retried, a backoff
|
|
35
|
+
* `retryDelayOf` cannot know) gets no fresh record. Both show as a record whose `until` is not the job's due time in
|
|
36
|
+
* the delayed set, which is how a reader tells a record that describes the wait from one that does not.
|
|
37
|
+
*/
|
|
38
|
+
import { randomBytes } from "node:crypto";
|
|
39
|
+
import { DEFER_N_RE, DEFER_WHY_RE, deferHsetArgs, deferKey, deferTtlMs } from "./defer-reasons.mjs";
|
|
40
|
+
|
|
41
|
+
/** How long a gate waits for its reason's write, and a failed move for its compare-and-delete, before going on. */
|
|
42
|
+
export const DEFER_WRITE_TIMEOUT_MS = 2_000;
|
|
43
|
+
|
|
44
|
+
/** Delete the record only while it still carries this writer's nonce: never another worker's later record. */
|
|
45
|
+
export const DEFER_CAD_LUA = "if redis.call('HGET', KEYS[1], 'n') == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The delay BullMQ will put on this job's retry after the current attempt throws, in ms, or null when it will not
|
|
49
|
+
* retry it on a delay this function can know. Mirrors the pinned BullMQ's `shouldRetryJob` and its builtin backoff
|
|
50
|
+
* strategies (job.js, backoffs.js at 5.80.4): it retries while `attemptsMade + 1 < opts.attempts` and the job is not
|
|
51
|
+
* discarded; a number is a `fixed` backoff; `fixed` waits `delay`, `exponential` waits `round(2^attemptsMade * delay)`
|
|
52
|
+
* (BullMQ calls the strategy with `attemptsMade + 1` and raises 2 to one less).
|
|
53
|
+
*
|
|
54
|
+
* Null for everything else, never a guess: a jittered backoff (its delay is random, so no instant could be told), a
|
|
55
|
+
* custom strategy (it is the queue's code), and a delay at or below zero (BullMQ then retries at once, through the
|
|
56
|
+
* wait list rather than the delayed set).
|
|
57
|
+
*/
|
|
58
|
+
export function retryDelayOf(job) {
|
|
59
|
+
const attempts = job?.opts?.attempts;
|
|
60
|
+
const made = job?.attemptsMade;
|
|
61
|
+
if (!Number.isInteger(attempts) || !Number.isInteger(made) || made < 0) return null;
|
|
62
|
+
if (!(made + 1 < attempts) || job.discarded === true) return null;
|
|
63
|
+
const raw = job.opts.backoff;
|
|
64
|
+
const backoff = Number.isFinite(raw) ? { type: "fixed", delay: raw } : raw;
|
|
65
|
+
if (backoff === null || typeof backoff !== "object") return null;
|
|
66
|
+
// BullMQ's own test (`jitter > 0`), so a value it ignores is ignored here too.
|
|
67
|
+
if (backoff.jitter > 0) return null;
|
|
68
|
+
const delay = backoff.delay;
|
|
69
|
+
if (!Number.isFinite(delay) || delay <= 0) return null;
|
|
70
|
+
if (backoff.type === "fixed") return delay;
|
|
71
|
+
if (backoff.type === "exponential") return Math.round(2 ** made * delay);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Settle `promise` or give up after `ms`, never rejecting: `{ value }`, `{ error }` or `{ timedOut: true }`. */
|
|
76
|
+
function settleWithin(promise, ms) {
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
const timer = setTimeout(() => resolve({ timedOut: true }), ms);
|
|
79
|
+
Promise.resolve(promise).then(
|
|
80
|
+
(value) => {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
resolve({ value });
|
|
83
|
+
},
|
|
84
|
+
(error) => {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
resolve({ error });
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A failure's name for the log: a fixed word or an errno-like code, never an error's message (it can quote a URL). */
|
|
93
|
+
function failureOf(outcome) {
|
|
94
|
+
if (outcome.timedOut) return "timeout";
|
|
95
|
+
if (outcome.threw) return "threw";
|
|
96
|
+
if (outcome.error) return typeof outcome.error.code === "string" && /^[A-Z][A-Z0-9_]{0,31}$/.test(outcome.error.code) ? outcome.error.code : "rejected";
|
|
97
|
+
return "error-reply";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build the writer for one processor. `redis` is the processor's client; `DelayedError` is BullMQ's class (required:
|
|
102
|
+
* a stand-in would pass BullMQ's name test and fail the processor's `instanceof`, so a deferral would read as a
|
|
103
|
+
* failure there); `hostName` is this worker's declared name, recorded as the record's host when it passes the host
|
|
104
|
+
* rule; `now` is the processor's clock, the one every gate's due time is computed from.
|
|
105
|
+
*
|
|
106
|
+
* Returns:
|
|
107
|
+
* - `writeReason(job, { why, untilMs, atMs, project, holder, endpoint, budgetWhy, attempt, attempts })`: the record's
|
|
108
|
+
* write, bounded. `atMs` is the record's `at`, by default the clock when the write is built; a caller that measured
|
|
109
|
+
* `untilMs` from its own reading passes that reading, so the two are exactly the delay apart. Resolves `{ n }` once
|
|
110
|
+
* the MULTI was sent (it landed, or may still land: see the header) and null when nothing was sent: a client with
|
|
111
|
+
* no `multi` (a bare test wiring), a job with no id, or a write that could not be built. Never rejects. `why` must
|
|
112
|
+
* be a reason of `DEFER_WHYS`; any other field that fails its rule is left out of the record rather than failing it.
|
|
113
|
+
* - `deferTo(job, token, untilMs, why, extra)`: the one deferral. Writes the reason, then `job.moveToDelayed(untilMs,
|
|
114
|
+
* token)`, then throws BullMQ's `DelayedError`, the throw by which BullMQ learns the job was deferred. NEVER
|
|
115
|
+
* returns. A rejected move removes this write's record and is rethrown unchanged.
|
|
116
|
+
* - `noteRetry(job, { project })`: before an infrastructure retry's throw, the `retry` record, when BullMQ will
|
|
117
|
+
* delay the retry by a known amount (`retryDelayOf`). Never throws.
|
|
118
|
+
*
|
|
119
|
+
* The log says `defer_reason_unwritten { why, reason }` on the first failed write and nothing more until a write
|
|
120
|
+
* succeeds again, which says `defer_reason_restored { why }` once: an outage is one episode, not a line per wake.
|
|
121
|
+
*/
|
|
122
|
+
export function makeDeferTo({ redis, DelayedError, now = () => Date.now(), hostName = "", log = () => {}, nonce = () => randomBytes(8).toString("hex"), timeoutMs = DEFER_WRITE_TIMEOUT_MS } = {}) {
|
|
123
|
+
if (typeof DelayedError !== "function") throw new TypeError("makeDeferTo needs BullMQ's DelayedError class, the one the processor's catch recognises a deferral by");
|
|
124
|
+
let failing = false;
|
|
125
|
+
const said = (why) => (typeof why === "string" && DEFER_WHY_RE.test(why) ? why : null);
|
|
126
|
+
// Every line goes through `say`, so a logger that throws is swallowed here: the log describes a deferral, and a line
|
|
127
|
+
// that could not be written must not turn the deferral into a rejection before its move (the job left active, BullMQ
|
|
128
|
+
// counting a failed attempt). The episode's state is set before the line, so it holds even when the line is lost.
|
|
129
|
+
const say = (event, fields) => {
|
|
130
|
+
try {
|
|
131
|
+
log(event, fields);
|
|
132
|
+
} catch {
|
|
133
|
+
// The line is lost, the deferral goes on.
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const failed = (why, outcome) => {
|
|
137
|
+
if (failing) return;
|
|
138
|
+
failing = true;
|
|
139
|
+
say("defer_reason_unwritten", { why: said(why), reason: failureOf(outcome) });
|
|
140
|
+
};
|
|
141
|
+
const succeeded = (why) => {
|
|
142
|
+
if (!failing) return;
|
|
143
|
+
failing = false;
|
|
144
|
+
say("defer_reason_restored", { why: said(why) });
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const writeReason = async (job, { why, untilMs, atMs, project = null, holder, endpoint, budgetWhy, attempt, attempts } = {}) => {
|
|
148
|
+
if (typeof redis?.multi !== "function") return null;
|
|
149
|
+
if (typeof job?.id !== "string" || job.id === "") return null;
|
|
150
|
+
let sent;
|
|
151
|
+
let n = null;
|
|
152
|
+
try {
|
|
153
|
+
const at = Number.isFinite(atMs) ? Math.floor(atMs) : now();
|
|
154
|
+
const drawn = nonce();
|
|
155
|
+
n = typeof drawn === "string" && DEFER_N_RE.test(drawn) ? drawn : null;
|
|
156
|
+
// Whole milliseconds: the record's rule takes integers, and a gate's arithmetic (a jittered delay, a keeper's
|
|
157
|
+
// wait) need not produce one.
|
|
158
|
+
const until = Number.isFinite(untilMs) ? Math.floor(untilMs) : null;
|
|
159
|
+
const args = deferHsetArgs({ why, at, until, project, host: hostName, holder, endpoint, budgetWhy, trigger: job.data?.trigger?.matched?.index, attempt, attempts, n });
|
|
160
|
+
const key = deferKey(job.id);
|
|
161
|
+
const ttl = Math.ceil(deferTtlMs(until ?? at, at));
|
|
162
|
+
sent = redis.multi().del(key).hset(key, ...args).pexpire(key, ttl).exec();
|
|
163
|
+
} catch (error) {
|
|
164
|
+
// Nothing was sent: a client whose transaction could not be built, or a reason outside the table (a defect at
|
|
165
|
+
// the call site, which `defer-write.test.mjs` pins by literal).
|
|
166
|
+
failed(why, { threw: true, error });
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
const outcome = await settleWithin(sent, timeoutMs);
|
|
170
|
+
const replies = outcome.value;
|
|
171
|
+
const errorReply = Array.isArray(replies) ? replies.some((reply) => Array.isArray(reply) && reply[0]) : replies !== undefined;
|
|
172
|
+
if (outcome.timedOut || outcome.error || errorReply) failed(why, outcome);
|
|
173
|
+
else succeeded(why);
|
|
174
|
+
return { n };
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const deferTo = async (job, token, untilMs, why, extra = {}) => {
|
|
178
|
+
const written = await writeReason(job, { ...extra, why, untilMs });
|
|
179
|
+
try {
|
|
180
|
+
await job.moveToDelayed(untilMs, token);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (written?.n && typeof redis?.eval === "function") {
|
|
183
|
+
let removal;
|
|
184
|
+
try {
|
|
185
|
+
removal = redis.eval(DEFER_CAD_LUA, 1, deferKey(job.id), written.n);
|
|
186
|
+
} catch {
|
|
187
|
+
removal = null;
|
|
188
|
+
}
|
|
189
|
+
await settleWithin(removal, timeoutMs);
|
|
190
|
+
}
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
throw new DelayedError();
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const noteRetry = async (job, { project = null } = {}) => {
|
|
197
|
+
try {
|
|
198
|
+
const delay = retryDelayOf(job);
|
|
199
|
+
if (delay === null) return;
|
|
200
|
+
const at = now();
|
|
201
|
+
await writeReason(job, { why: "retry", untilMs: at + delay, atMs: at, project, attempt: job.attemptsMade + 2, attempts: job.opts.attempts });
|
|
202
|
+
} catch {
|
|
203
|
+
// Never in the way of the throw it precedes: the retry happens whether or not its reason was written.
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
return { writeReason, deferTo, noteRetry };
|
|
208
|
+
}
|
package/src/deployment-venue.mjs
CHANGED
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
* different venue than the service they described. The deployment's `.env` is read with `readStackKeys`, exactly as
|
|
5
5
|
* `service install` reads it, and a key this shell sets differently stops the command, since the service runs the file.
|
|
6
6
|
*/
|
|
7
|
-
import { parseBackendList } from "./backends.mjs";
|
|
7
|
+
import { parseBackendList, venuesOf } from "./backends.mjs";
|
|
8
8
|
import { egressArmed, egressProxyName } from "./egress.mjs";
|
|
9
9
|
import { envValueShown } from "./env-file.mjs";
|
|
10
10
|
import { STACK_KEYS, VALKEY_SHARED_KEY, readStackKeys, readValkeyKeys } from "./podman-stack.mjs";
|
|
11
11
|
|
|
12
|
+
/** The venue keys only the openshell venues read (issue #707). */
|
|
13
|
+
const OPENSHELL_KEYS = Object.freeze(["PI_OPENSHELL_ENGINE", "PI_OPENSHELL_GATEWAY"]);
|
|
14
|
+
|
|
12
15
|
/** How each command says what a shell/file disagreement would make it do, the clause before "while the service ...". */
|
|
13
16
|
const COMMAND_SAYS = Object.freeze({
|
|
14
17
|
up: "up would drive the shell's venue",
|
|
@@ -39,10 +42,16 @@ export function deploymentVenueEnv({ env, fs, envPath, platform, command = "up",
|
|
|
39
42
|
text = fs.readFileSync(envPath);
|
|
40
43
|
} catch (err) {
|
|
41
44
|
// Said rather than silent (round 2 nit): the venue then comes from this shell alone.
|
|
42
|
-
notes.push(`${envPath} could not be read (${err?.message}), so
|
|
45
|
+
notes.push(`${envPath} could not be read (${err?.message}), so the venue keys (${STACK_KEYS.join(", ")}) come from this shell alone`);
|
|
43
46
|
}
|
|
44
47
|
if (text !== null) {
|
|
45
|
-
|
|
48
|
+
// The openshell keys are refused only where an openshell venue is blessed (review round 2 of #715): the file's
|
|
49
|
+
// PI_BACKENDS says so to the reader itself, this shell's is said here.
|
|
50
|
+
let shellOpenshell = false;
|
|
51
|
+
try {
|
|
52
|
+
shellOpenshell = typeof env.PI_BACKENDS === "string" && venuesOf({ PI_BACKENDS: env.PI_BACKENDS }).openshellUsed;
|
|
53
|
+
} catch {}
|
|
54
|
+
const read = readStackKeys(text, { loader, path: envPath, openshellBlessed: shellOpenshell });
|
|
46
55
|
if (read.error && linux) return { error: read.error };
|
|
47
56
|
if (read.error) notes.push(`${read.error}; off Linux the podman venue refuses this host anyway, so this pass reads the venue from this shell`);
|
|
48
57
|
else keys = read.keys;
|
|
@@ -57,7 +66,7 @@ export function deploymentVenueEnv({ env, fs, envPath, platform, command = "up",
|
|
|
57
66
|
if (typeof env[key] === "string") {
|
|
58
67
|
// Compared as the worker READS them (round 3, D4), not as strings: ` podman` and `podman,podman` are the list
|
|
59
68
|
// `podman`, and refusing them sent an operator to reconcile two values that already agree.
|
|
60
|
-
if (venueKeyMeaning(key, env[key]) !== venueKeyMeaning(key, keys[key])) conflicts.push(`${key} is ${quotedShown(env[key])} in this shell and ${quotedShown(keys[key])} in ${envPath}`);
|
|
69
|
+
if (venueKeyMeaning(key, env[key]) !== venueKeyMeaning(key, keys[key])) conflicts.push({ key, text: `${key} is ${quotedShown(env[key])} in this shell and ${quotedShown(keys[key])} in ${envPath}` });
|
|
61
70
|
continue;
|
|
62
71
|
}
|
|
63
72
|
merged[key] = keys[key];
|
|
@@ -66,8 +75,12 @@ export function deploymentVenueEnv({ env, fs, envPath, platform, command = "up",
|
|
|
66
75
|
// A KNOWN disagreement is a refusal (round 2, E5), not a warning: this pass would stand up one venue's stack and the
|
|
67
76
|
// service installed next would run the other. The file is what the service runs, so it is the one to change unless
|
|
68
77
|
// the shell's value was a one-off.
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
// The openshell venues' two keys disagree only where one of those venues is blessed (review round 1 of #715): elsewhere
|
|
79
|
+
// neither the worker nor any command reads them, so a stray value in one place is no reason to stop.
|
|
80
|
+
const openshellBlessed = venuesOf(merged).openshellUsed;
|
|
81
|
+
const said = conflicts.filter((c) => openshellBlessed || !OPENSHELL_KEYS.includes(c.key)).map((c) => c.text);
|
|
82
|
+
if (said.length > 0) {
|
|
83
|
+
return { error: `${said.join("; ")}. ${COMMAND_SAYS[command] ?? COMMAND_SAYS.up} while the service runs the file's. Make them agree: change ${envPath} (what the service reads), or unset the key in this shell` };
|
|
71
84
|
}
|
|
72
85
|
return { env: merged, fromFile, notes };
|
|
73
86
|
}
|
|
@@ -146,6 +159,10 @@ function venueKeyMeaning(key, value) {
|
|
|
146
159
|
try {
|
|
147
160
|
if (key === "PI_BACKENDS") return `list:${parseBackendList(value).join(",")}`;
|
|
148
161
|
if (key === "PI_EGRESS") return `egress:${egressArmed({ PI_EGRESS: value }) ? "on" : "off"}`;
|
|
162
|
+
// Issue #707: the openshell venues' two keys, as `openshellFrom` reads them: an empty engine is docker, an empty
|
|
163
|
+
// gateway the CLI's active one. Not trimmed, as the worker trims neither.
|
|
164
|
+
if (key === "PI_OPENSHELL_ENGINE") return `engine:${value === "" ? "docker" : value}`;
|
|
165
|
+
if (key === "PI_OPENSHELL_GATEWAY") return `gateway:${value}`;
|
|
149
166
|
return `proxy:${egressProxyName({ PI_EGRESS_PROXY: value })}`;
|
|
150
167
|
} catch {
|
|
151
168
|
return `raw:${value}`;
|
package/src/docker-run.mjs
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
export { containerSpec, CONTAINER_GLOBAL_PI_DIR, CONTAINER_SESSION_DIR, CONTAINER_SESSION_FILE } from "./container-spec.mjs";
|
|
23
23
|
import { isAbsolute, relative } from "node:path";
|
|
24
24
|
import { SIZE_LABEL_CPU, SIZE_LABEL_MEM, assertCidFile, assertJobUser, containerSpec } from "./container-spec.mjs";
|
|
25
|
+
import { LAYOUTS } from "./job-layout.mjs";
|
|
25
26
|
import { CGROUP_PARENT, CPU_SHARES_MAX, CPU_SHARES_MIN } from "./job-size.mjs";
|
|
26
27
|
|
|
27
28
|
/** The fixed isolation flags. Not configurable -- these ARE the boundary. */
|
|
@@ -165,6 +166,10 @@ export const DOCKER_EXTRA_FORBIDDEN = [
|
|
|
165
166
|
// The job user is a spec field (issue #341). Here so a repeat cannot override it with uid 0.
|
|
166
167
|
"--user",
|
|
167
168
|
"-u",
|
|
169
|
+
// Issue #683: the builder pins the working directory (see `--workdir=` in `argsFromSpec`). A repeat would start the
|
|
170
|
+
// job outside its workspace, where a relative path the agent writes lands in the image's own tree.
|
|
171
|
+
"--workdir",
|
|
172
|
+
"-w",
|
|
168
173
|
];
|
|
169
174
|
|
|
170
175
|
/**
|
|
@@ -331,6 +336,12 @@ function argsFromSpec(spec, { userns }) {
|
|
|
331
336
|
args.push("-v", `${m.host}:${m.container}${options.length > 0 ? `:${options.join(",")}` : ""}`);
|
|
332
337
|
}
|
|
333
338
|
|
|
339
|
+
// Issue #683: the job starts in its workspace mount because the ARGV says so, not because the image's WORKDIR
|
|
340
|
+
// happens to match. The shipped image's WORKDIR is now /sandbox (a copying venue's writable tree, image/Dockerfile),
|
|
341
|
+
// so without this a binding venue's job would start in an empty /sandbox and every relative path the agent writes
|
|
342
|
+
// would land outside the repository it was asked to change. A binding venue always uses the `mounts` layout. An
|
|
343
|
+
// older image, whose WORKDIR was /workspace, starts exactly where it did.
|
|
344
|
+
args.push(`--workdir=${LAYOUTS.mounts.workspace}`);
|
|
334
345
|
args.push(spec.image);
|
|
335
346
|
return args;
|
|
336
347
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doctor's project secret bundle lines (issue #704, REQ-TRIGGER-SECRETS, DES-PROJECT-SECRET-BUNDLES).
|
|
3
|
+
*
|
|
4
|
+
* Its own module, the doctor-openshell.mjs precedent: doctor.mjs is a ten-thousand-line file, and these lines read
|
|
5
|
+
* three files and (with `--secrets`) spawn resolvers, so a seam per input keeps them testable without a host.
|
|
6
|
+
*
|
|
7
|
+
* WHAT IS CHECKED, and why each is a failure rather than a warning:
|
|
8
|
+
* - a bundle naming a profile this host does not declare (or declares twice, or declares in the panel outside
|
|
9
|
+
* PI_SECRET_RESOLVER_ROOTS): every job of that project refuses pre-spend on this host, deliberately;
|
|
10
|
+
* - a bundle naming a project projects.json does not hold: the binding can never apply, and the operator reads it
|
|
11
|
+
* as granting an environment. That is the silent no-op this project refuses;
|
|
12
|
+
* - bundle profiles declared with no triggers file to read: no job on this host can get a bundle, while the host
|
|
13
|
+
* reads as serving them.
|
|
14
|
+
* Two bundles naming one project is refused by the triggers file's own validator, so it surfaces as that file's
|
|
15
|
+
* parse failure, which doctor already prints, rather than as a second line here.
|
|
16
|
+
*
|
|
17
|
+
* And a WARNING per bundle whose project holds a folder: a local job's /workspace IS the operator's folder, so an
|
|
18
|
+
* agent that writes a credential to `.env` there writes the whole bundle's into a real repository. The run.secrets
|
|
19
|
+
* disclosure, raised to a warning because a bundle is a whole environment, not one field.
|
|
20
|
+
*
|
|
21
|
+
* `--secrets` (`secrets: true`) resolves each bundle ON THIS HOST and prints its variable NAMES and their count, never
|
|
22
|
+
* a value. It is opt-in because it spawns every bundle resolver, which reads the manager (and counts against its
|
|
23
|
+
* rate limit) once per bundle.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { FORGE_KINDS } from "./forges.mjs";
|
|
27
|
+
import { mergeSecretProfiles, parseSecretBundleProfiles, servableSecretProfiles } from "./secret-profiles.mjs";
|
|
28
|
+
|
|
29
|
+
/** Whether a projects.json member is a FOLDER (a local job's scope), not a forge-qualified repository. */
|
|
30
|
+
function isFolderMember(member) {
|
|
31
|
+
return typeof member === "string" && !FORGE_KINDS.some((kind) => member.startsWith(`${kind}:`));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `PI_SECRET_BUNDLES`, never throwing: `{ bundles }` or `{ error }` (the worker refuses to boot on the latter). */
|
|
35
|
+
export function parseSecretBundleProfilesSafe(raw) {
|
|
36
|
+
try {
|
|
37
|
+
return { bundles: parseSecretBundleProfiles(raw), error: null };
|
|
38
|
+
} catch (err) {
|
|
39
|
+
return { bundles: {}, error: err?.message ?? "unparseable" };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The checks. Inputs, every one already read by the caller:
|
|
45
|
+
* - `bundles`: the triggers file's validated `secretBundles` (`{}` when none);
|
|
46
|
+
* - `triggersPresent`: whether the worker has a triggers file to read them from;
|
|
47
|
+
* - `projects` / `projectsError`: projects.json as the worker loads it, or its load failure;
|
|
48
|
+
* - `envRaw`: `PI_SECRET_BUNDLES` as set; `overlayBundles`: the overlay's `secretBundles`; `roots`: PI_SECRET_RESOLVER_ROOTS;
|
|
49
|
+
* - `realExecutablePath`: the resolver's own probe (secrets.mjs `realExecutable`), or null when pi did not load;
|
|
50
|
+
* - `resolveBundle`: `(binding) => resolve result`, only with `--secrets`.
|
|
51
|
+
*/
|
|
52
|
+
export async function secretBundleChecks({ bundles = {}, triggersPresent = true, projects = [], projectsError = null, envRaw, overlayBundles = {}, roots = [], realExecutablePath = null, resolveBundle = null } = {}) {
|
|
53
|
+
const checks = [];
|
|
54
|
+
const { bundles: envBundles, error } = parseSecretBundleProfilesSafe(envRaw);
|
|
55
|
+
if (error) {
|
|
56
|
+
checks.push({ ok: false, label: "PI_SECRET_BUNDLES does not parse", fix: `${error} -- the worker refuses to boot until this is fixed` });
|
|
57
|
+
return checks;
|
|
58
|
+
}
|
|
59
|
+
const ids = Object.keys(bundles);
|
|
60
|
+
const declaredNames = [...new Set([...Object.keys(envBundles), ...Object.keys(overlayBundles)])].sort();
|
|
61
|
+
if (ids.length === 0 && declaredNames.length === 0) return checks;
|
|
62
|
+
|
|
63
|
+
// The overlay half counts only where the worker would use it: an executable inside the roots (servableSecretProfiles
|
|
64
|
+
// asks the resolver's own question). Without the probe (pi did not load) an overlay profile counts as written.
|
|
65
|
+
const overlayPaths = Object.fromEntries(Object.entries(overlayBundles).map(([name, entry]) => [name, entry?.path]));
|
|
66
|
+
const usableOverlay = realExecutablePath === null ? overlayPaths : servableSecretProfiles({ envProfiles: {}, overlayProfiles: overlayPaths, roots, realExecutablePath }).profiles;
|
|
67
|
+
const ambiguous = mergeSecretProfiles(envBundles, overlayBundles).ambiguous !== undefined;
|
|
68
|
+
const ambiguousNames = Object.keys(overlayBundles).filter((name) => Object.hasOwn(envBundles, name));
|
|
69
|
+
|
|
70
|
+
if (declaredNames.length > 0) {
|
|
71
|
+
checks.push({ ok: true, label: `Secret bundle profiles declared: ${declaredNames.map((n) => `${n} (${(Object.hasOwn(envBundles, n) ? envBundles[n] : overlayBundles[n])?.format ?? "?"})`).join(", ")}` });
|
|
72
|
+
// The rate figure, stated once: what a manager's rate limit counts is job starts.
|
|
73
|
+
checks.push({ ok: true, label: "A project with a secret bundle costs one resolver call per job start (never one per variable), so a secrets manager's rate limit counts job starts: size it for your busiest hour of jobs across every bundled project" });
|
|
74
|
+
if (!triggersPresent) {
|
|
75
|
+
checks.push({ ok: false, label: "secret bundle profiles are declared, but the worker has no triggers file to read secretBundles from", fix: "set PI_TRIGGERS_FILE (or put triggers.json in the worker's folder) on this host -- the worker reads which project holds a bundle from its own copy of that file, so without it no job on this host gets one" });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (ambiguous) {
|
|
79
|
+
checks.push({ ok: false, label: `${ambiguousNames.length} secret bundle profile name(s) are declared in both PI_SECRET_BUNDLES and the settings overlay: ${ambiguousNames.join(", ")}`, fix: "remove each name from one of the two (neither source wins a collision) -- until then every bundled project's jobs refuse as secret-bundle-profile-ambiguous" });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const projectIds = new Set((projects ?? []).map((p) => p?.id));
|
|
83
|
+
for (const id of ids) {
|
|
84
|
+
const b = bundles[id];
|
|
85
|
+
const fromEnv = Object.hasOwn(envBundles, b.profile);
|
|
86
|
+
const fromOverlay = Object.hasOwn(overlayBundles, b.profile);
|
|
87
|
+
const format = fromEnv ? envBundles[b.profile].format : fromOverlay ? overlayBundles[b.profile]?.format : null;
|
|
88
|
+
checks.push({ ok: true, label: `secret bundle ${id}: profile ${b.profile}${format ? ` (${format})` : ""}, project ${b.project}${b.forkReviews === true ? ", fork reviews included" : ""}` });
|
|
89
|
+
if (!fromEnv && !fromOverlay) {
|
|
90
|
+
checks.push({ ok: false, label: `secret bundle ${id} names profile ${b.profile}, which this host does not declare`, fix: "declare it in PI_SECRET_BUNDLES as name:/absolute/path:format (format dotenv or json), on EVERY host that takes this project's jobs -- until then they refuse pre-spend as secret-bundle-profile-unknown" });
|
|
91
|
+
} else if (!fromEnv && !Object.hasOwn(usableOverlay, b.profile)) {
|
|
92
|
+
checks.push({ ok: false, label: `secret bundle ${id} names profile ${b.profile}, declared in the settings overlay but not usable`, fix: roots.length === 0 ? "PI_SECRET_RESOLVER_ROOTS is unset, so the worker uses no overlay-declared resolver: set it, or declare the profile in PI_SECRET_BUNDLES" : "the resolver must be an executable file whose real path sits inside PI_SECRET_RESOLVER_ROOTS" });
|
|
93
|
+
}
|
|
94
|
+
if (projectsError !== null) {
|
|
95
|
+
checks.push({ ok: false, label: `secret bundle ${id} names project ${b.project}, and projects.json does not load`, fix: "fix projects.json (its own line above says why) -- a bundle applies to a project's jobs only once the worker knows the project" });
|
|
96
|
+
} else if (!projectIds.has(b.project)) {
|
|
97
|
+
checks.push({ ok: false, label: `secret bundle ${id} names project ${b.project}, which projects.json does not hold`, fix: "add the project to PI_PROJECTS_FILE, or correct the bundle's project -- until then the bundle reaches no job, while the file reads as granting it" });
|
|
98
|
+
} else {
|
|
99
|
+
const project = projects.find((p) => p?.id === b.project);
|
|
100
|
+
const folders = (project?.members ?? []).filter(isFolderMember);
|
|
101
|
+
if (folders.length > 0) {
|
|
102
|
+
checks.push({ ok: true, warn: true, label: `secret bundle ${id}'s project ${b.project} holds ${folders.length} folder(s), whose local jobs run IN the operator's own folder with the whole bundle in their environment`, fix: "a value the agent writes to .env, .netrc or .git-credentials there lands in your real repository (and in a retained sandbox), and nothing scans for that: keep those folders out of anything you push, or set run.secretBundle: false on their triggers" });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (typeof resolveBundle === "function") {
|
|
108
|
+
for (const id of ids) {
|
|
109
|
+
const b = bundles[id];
|
|
110
|
+
let read;
|
|
111
|
+
try {
|
|
112
|
+
read = await resolveBundle({ id, ...b });
|
|
113
|
+
} catch {
|
|
114
|
+
read = { unreachable: true, failure: "spawn" };
|
|
115
|
+
}
|
|
116
|
+
if (read?.ok === true) {
|
|
117
|
+
const names = Object.keys(read.vars).sort();
|
|
118
|
+
checks.push({ ok: true, label: `secret bundle ${id} resolved on this host: ${names.length} variable(s): ${names.join(", ")}` });
|
|
119
|
+
} else if (read?.reserved !== undefined) {
|
|
120
|
+
checks.push({ ok: false, label: `secret bundle ${id} carries ${read.reserved}, a name this deployment reserves`, fix: "rename it in the secrets manager -- every job of the project refuses pre-spend as secret-name-reserved until you do" });
|
|
121
|
+
} else if (read?.profileUnknown !== undefined || read?.ambiguous !== undefined) {
|
|
122
|
+
// Already its own line above.
|
|
123
|
+
} else {
|
|
124
|
+
const what = read?.unreachable ? "could not reach its manager (exit 1, a timeout or a spawn fault: retried at job time)" : `was refused (${read?.failure ?? "unknown"}${read?.name ? `, variable ${read.name}` : ""}${read?.line ? `, line ${read.line}` : ""})`;
|
|
125
|
+
checks.push({ ok: false, label: `secret bundle ${id}'s resolver ${what}`, fix: "run the profile's resolver by hand with the bundle's ref as its argument -- doctor prints no byte of what it printed" });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return checks;
|
|
130
|
+
}
|