@edgehero/pi-dispatch 2.1.0 → 3.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 +41 -5
- package/README.md +11 -5
- package/deploy/docker-compose.yml +12 -0
- package/deploy/egress-proxy.conf +28 -3
- package/deploy/pi-dispatch-egress-proxy.container +8 -2
- package/package.json +9 -2
- package/src/allocation.mjs +731 -0
- package/src/backends.mjs +243 -0
- package/src/budget.mjs +40 -4
- package/src/cli.mjs +222 -11
- package/src/config.mjs +126 -5
- package/src/daemon-facts.mjs +3 -0
- package/src/deployment-venue.mjs +1 -0
- package/src/doctor.mjs +2280 -195
- package/src/dollar-budget.mjs +373 -0
- package/src/dollar-fingerprint.mjs +83 -0
- package/src/egress-cli.mjs +316 -0
- package/src/egress-proxy-state.mjs +35 -5
- package/src/egress.mjs +12 -0
- package/src/env-allowlist.mjs +125 -6
- package/src/env-file.mjs +194 -25
- package/src/envelope.mjs +413 -0
- package/src/exit-code.mjs +22 -0
- package/src/fleet-lease.mjs +85 -25
- package/src/get-token.mjs +16 -5
- package/src/git-dirty.mjs +67 -0
- package/src/github-app-setup.mjs +6 -3
- package/src/github-host.mjs +5 -3
- package/src/host-pi.mjs +1 -1
- package/src/identity.mjs +2 -1
- package/src/image-preflight.mjs +98 -24
- package/src/image-ref.mjs +37 -0
- package/src/import-pi.mjs +4 -2
- package/src/index.mjs +407 -62
- package/src/init.mjs +18 -0
- package/src/job-id.mjs +26 -3
- package/src/live-probes.mjs +24 -9
- package/src/model-catalog.mjs +297 -0
- package/src/model-endpoints.mjs +671 -0
- package/src/model-ref.mjs +151 -0
- package/src/models-json.mjs +268 -0
- package/src/money.mjs +144 -0
- package/src/octokit-log.mjs +65 -0
- package/src/outbox-plan.mjs +218 -0
- package/src/outbox.mjs +29 -9
- package/src/output-cap.mjs +157 -0
- package/src/pause-windows.mjs +81 -2
- package/src/pi-model-loader.mjs +77 -0
- package/src/podman-stack.mjs +16 -3
- package/src/portfolio-snapshot.mjs +304 -0
- package/src/prepare-local.mjs +247 -12
- package/src/prepare.mjs +35 -3
- package/src/priorities.mjs +569 -0
- package/src/processor.mjs +599 -170
- package/src/project-id.mjs +17 -0
- package/src/projects.mjs +238 -0
- package/src/provider-steering.mjs +179 -65
- package/src/queue.mjs +111 -6
- package/src/reserved-env.mjs +31 -0
- package/src/run-container.mjs +59 -5
- package/src/run-history.mjs +379 -24
- package/src/run-mirror.mjs +30 -0
- package/src/runtime-settings.mjs +104 -9
- package/src/schedules.mjs +33 -1
- package/src/scoped-limits.mjs +447 -27
- package/src/service.mjs +15 -4
- package/src/session-store.mjs +131 -6
- package/src/start.mjs +528 -40
- package/src/triggers-file.mjs +65 -4
- package/src/triggers.mjs +141 -11
- package/src/up.mjs +308 -34
- package/src/valkey-endpoint.mjs +3 -2
package/src/scoped-limits.mjs
CHANGED
|
@@ -23,21 +23,102 @@
|
|
|
23
23
|
* worker drops would be a silently WIDENED spend limit. Pause-windows shipping without a version is a
|
|
24
24
|
* sunk decision, not a precedent to extend to enforcement config.
|
|
25
25
|
*
|
|
26
|
+
* Version 2 (issues #501 part 5, #502 part 6) is exactly that case made real: dollar windows (`dayUsd`,
|
|
27
|
+
* `weekUsd`, `monthUsd`) on a scope row, and `model:<provider>/<model>` rows carrying those alone. They are
|
|
28
|
+
* DOLLAR ledgers (dollar-budget.mjs, built here by `dollarCapsFor` and `modelDollarRows`), never job-count ones,
|
|
29
|
+
* so `budgetCapsFor`, `concurrencyFor` and the mutex never see them.
|
|
30
|
+
*
|
|
31
|
+
* Issue #498 adds the forge-qualified scope (`github:acme/web`), which names one forge's repo where a bare
|
|
32
|
+
* `acme/web` names that repo on every forge. A job matches its qualified row first, then its bare row, and a file
|
|
33
|
+
* holding both for one repo is refused. EVERY key (the job-count windows, the dollar windows, the in-process slot and
|
|
34
|
+
* the fleet lease) is built from the MATCHED ROW's scope, never from the job's, so a bare row keeps exactly the key
|
|
35
|
+
* it always had and a qualified row counts on its own.
|
|
36
|
+
*
|
|
37
|
+
* Issue #499 part B adds the project row (`project:<id>`, the id from projects.json; version 2). It carries the job-count windows,
|
|
38
|
+
* `concurrent` and the dollar windows, and it caps every member of the project as one: a job reserves in
|
|
39
|
+
* its repo or folder row, then its project's row, then the global windows (`scopedLedgers`), and its keys are built from
|
|
40
|
+
* the project ROW's scope like every other row's, so a project needs no keyspace of its own.
|
|
41
|
+
*
|
|
26
42
|
* Custom: scoped limits validated inline per triggers.mjs/pause-windows.mjs precedent; zod not in deps
|
|
27
43
|
*/
|
|
28
44
|
|
|
29
|
-
import { createHash } from "node:crypto";
|
|
30
45
|
import { existsSync as fsExistsSync, readFileSync as fsReadFileSync } from "node:fs";
|
|
31
46
|
import { isAbsolute, resolve } from "node:path";
|
|
32
47
|
import { configError } from "./config.mjs";
|
|
33
|
-
import {
|
|
48
|
+
import { DOLLAR_KEY_PREFIX } from "./dollar-budget.mjs";
|
|
49
|
+
import { hash16 } from "./fleet-lease.mjs";
|
|
50
|
+
import { splitModelEntry } from "./model-ref.mjs";
|
|
51
|
+
import { formatMicros, parseUsdMicros } from "./money.mjs";
|
|
52
|
+
import { parseScopeString, qualifiedScopeOf, scopeOf } from "./pause-windows.mjs";
|
|
53
|
+
import { PROJECT_ID_RE, isProjectId } from "./project-id.mjs";
|
|
34
54
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
55
|
+
/**
|
|
56
|
+
* The highest schema version this build reads. A file declaring a higher one is refused loudly. The admin writes the
|
|
57
|
+
* LOWEST version that expresses a file (`scopedLimitsVersionFor`), so a file with no dollar or model row stays 1.
|
|
58
|
+
*
|
|
59
|
+
* Version 2 (issues #501 part 5 and #502 part 6) adds dollar windows: `dayUsd`, `weekUsd` and `monthUsd` on a repo or
|
|
60
|
+
* folder row, and `model:<provider>/<model>` rows that carry those three fields only. A version 1 file that uses
|
|
61
|
+
* either is refused with an error naming version 2: a version 1 worker drops unknown fields, so a file that says 1
|
|
62
|
+
* while it means 2 would read as a narrower cap on one build and no cap on another.
|
|
63
|
+
*
|
|
64
|
+
* A forge-qualified row (`github:acme/web`, issue #498) needs version 2 as well, for the same reason: every released
|
|
65
|
+
* build reads `github:acme/web` as a plain repo string no job ever has, so a version 1 file holding one would be a
|
|
66
|
+
* cap, a concurrency limit and a lease that one build enforces and another silently ignores. Version 2 makes every
|
|
67
|
+
* older build refuse the file loudly instead.
|
|
68
|
+
*/
|
|
69
|
+
export const SCOPED_LIMITS_VERSION = 2;
|
|
37
70
|
|
|
38
|
-
/** The four limit fields a row may carry, in display order. */
|
|
71
|
+
/** The four job-count limit fields a row may carry, in display order. */
|
|
39
72
|
const LIMIT_FIELDS = ["day", "week", "month", "concurrent"];
|
|
40
73
|
|
|
74
|
+
/** The three dollar window fields (version 2), in display order. Each maps to a dollar window: day, week, month. */
|
|
75
|
+
export const USD_LIMIT_FIELDS = Object.freeze(["dayUsd", "weekUsd", "monthUsd"]);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The scope prefix of a per-model row (issue #502 part 6): `model:<provider>/<model>`. Reserved in BOTH versions. A
|
|
79
|
+
* version 1 file naming it is refused (naming version 2) rather than read as a folder called `model:...`.
|
|
80
|
+
*/
|
|
81
|
+
export const MODEL_SCOPE_PREFIX = "model:";
|
|
82
|
+
|
|
83
|
+
/** The dollar key prefix of a repo or folder row: `budget:usd:s:<hash16>` (the deployment's is `budget:usd`). */
|
|
84
|
+
export const SCOPE_DOLLAR_KEY_PREFIX = `${DOLLAR_KEY_PREFIX}:s`;
|
|
85
|
+
/** The dollar key prefix of a model row: `budget:usd:mdl:<hash16>`. */
|
|
86
|
+
export const MODEL_DOLLAR_KEY_PREFIX = `${DOLLAR_KEY_PREFIX}:mdl`;
|
|
87
|
+
|
|
88
|
+
/** A scope that LOOKS like a model row (any case, `models`, spaces before the colon) but is not the exact prefix form. */
|
|
89
|
+
const MODEL_NEAR_MISS = /^models?\s*:/i;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The scope prefix of a project row (issue #499 part B): `project:<id>`, the id from projects.json. A project row needs
|
|
93
|
+
* version 2 in EVERY field, its job counts and `concurrent` included, for the forge-qualified row's reason: the last
|
|
94
|
+
* released build (2.1.0) reads `project:shop` as a plain repo string no job has, so a version 1 file holding one would
|
|
95
|
+
* be a cap and a concurrency limit that one build enforces and another silently drops. Version 2 makes that build
|
|
96
|
+
* refuse the file as newer instead.
|
|
97
|
+
*/
|
|
98
|
+
export const PROJECT_SCOPE_PREFIX = "project:";
|
|
99
|
+
/** A scope that LOOKS like a project row (any case, `projects`, spaces before the colon). Only the exact form parses. */
|
|
100
|
+
const PROJECT_NEAR_MISS = /^projects?\s*:/i;
|
|
101
|
+
|
|
102
|
+
/** Is this row scope a per-model row? */
|
|
103
|
+
export function isModelScope(scope) {
|
|
104
|
+
return typeof scope === "string" && scope.startsWith(MODEL_SCOPE_PREFIX);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Is this row scope a project row? */
|
|
108
|
+
export function isProjectScope(scope) {
|
|
109
|
+
return typeof scope === "string" && scope.startsWith(PROJECT_SCOPE_PREFIX);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The row scope of a project: `project:<id>`. Every key of a project row hashes this string. */
|
|
113
|
+
export function projectScope(id) {
|
|
114
|
+
return `${PROJECT_SCOPE_PREFIX}${id}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A row that is never a job's own scope: a model row or a project row. `limitFor` and the scope advisories skip them. */
|
|
118
|
+
function isGroupScope(scope) {
|
|
119
|
+
return isModelScope(scope) || isProjectScope(scope);
|
|
120
|
+
}
|
|
121
|
+
|
|
41
122
|
function isNonEmptyString(value) {
|
|
42
123
|
return typeof value === "string" && value.trim() !== "";
|
|
43
124
|
}
|
|
@@ -75,7 +156,8 @@ export function canonicalScope(job) {
|
|
|
75
156
|
|
|
76
157
|
/**
|
|
77
158
|
* Parse, validate, and normalize the scoped-limits file TEXT. Returns the normalized `limits` array
|
|
78
|
-
* (every row rebuilt as an explicit `{ scope, day, week, month, concurrent }` literal
|
|
159
|
+
* (every row rebuilt as an explicit `{ scope, day, week, month, concurrent }` literal in a version 1 file, and
|
|
160
|
+
* `{ scope, day, week, month, concurrent, dayUsd, weekUsd, monthUsd }` in a version 2 one, `null` for absent
|
|
79
161
|
* fields, unknown fields dropped -- the operator-file policy). Throws `configError` (fail-loud) on any
|
|
80
162
|
* malformed entry. `path` is for error messages only -- this function touches no filesystem.
|
|
81
163
|
*/
|
|
@@ -91,7 +173,7 @@ export function parseScopedLimits(text, path) {
|
|
|
91
173
|
}
|
|
92
174
|
const version = parsed.version;
|
|
93
175
|
if (!Number.isInteger(version) || version < 1) {
|
|
94
|
-
throw configError(`scoped-limits file must have "version": 1 (an integer >= 1): ${path}`);
|
|
176
|
+
throw configError(`scoped-limits file must have "version": 1 or ${SCOPED_LIMITS_VERSION} (an integer >= 1): ${path}`);
|
|
95
177
|
}
|
|
96
178
|
if (version > SCOPED_LIMITS_VERSION) {
|
|
97
179
|
throw configError(`scoped-limits file written by a newer pi-dispatch (version ${version}; this build understands ${SCOPED_LIMITS_VERSION}): ${path}`);
|
|
@@ -99,26 +181,98 @@ export function parseScopedLimits(text, path) {
|
|
|
99
181
|
if (!Array.isArray(parsed.limits)) {
|
|
100
182
|
throw configError(`scoped-limits file must have a "limits" array: ${path}`);
|
|
101
183
|
}
|
|
102
|
-
const rows = parsed.limits.map((row, index) => normalizeLimit(row, index, path));
|
|
184
|
+
const rows = parsed.limits.map((row, index) => normalizeLimit(row, index, path, version));
|
|
185
|
+
refuseMixedForms(rows, path);
|
|
103
186
|
const seen = new Map();
|
|
104
187
|
rows.forEach((row, index) => {
|
|
105
|
-
|
|
188
|
+
// A model row's identity is its LOWERCASED ref, the one its dollar key hashes (`modelDollarKeyPrefix`): two rows
|
|
189
|
+
// differing only in case would be two caps on one counter.
|
|
190
|
+
const id = isModelScope(row.scope) ? row.scope.toLowerCase() : row.scope;
|
|
191
|
+
if (seen.has(id)) {
|
|
106
192
|
// Two rows for one scope is a precedence question with no right answer; the admin's
|
|
107
193
|
// edit-in-place never produces one, so a duplicate is always a hand-edit mistake.
|
|
108
|
-
throw configError(`scoped limit at index ${index}: duplicate scope ${JSON.stringify(row.scope)} (first at index ${seen.get(
|
|
194
|
+
throw configError(`scoped limit at index ${index}: duplicate scope ${JSON.stringify(row.scope)} (first at index ${seen.get(id)}): ${path}`);
|
|
109
195
|
}
|
|
110
|
-
seen.set(
|
|
196
|
+
seen.set(id, index);
|
|
111
197
|
});
|
|
112
198
|
return rows;
|
|
113
199
|
}
|
|
114
200
|
|
|
115
|
-
|
|
201
|
+
/**
|
|
202
|
+
* A bare row and a qualified row for one repo (`acme/web` beside `github:acme/web`) refuse the file, naming both
|
|
203
|
+
* indexes (issue #498). Whatever fields either carries, count or dollar: one row applies to a job, and with both forms
|
|
204
|
+
* present that rule would need a precedence ladder, which reads as one cap while the other silently stops counting.
|
|
205
|
+
* Refusing is the simpler rule, and it is loud. Two qualified rows for one repo on two forges are fine.
|
|
206
|
+
*/
|
|
207
|
+
function refuseMixedForms(rows, path) {
|
|
208
|
+
const bare = new Map();
|
|
209
|
+
rows.forEach((row, index) => {
|
|
210
|
+
if (!isGroupScope(row.scope) && parseScopeString(row.scope).type === "bare") bare.set(row.scope, index);
|
|
211
|
+
});
|
|
212
|
+
rows.forEach((row, index) => {
|
|
213
|
+
if (isGroupScope(row.scope)) return;
|
|
214
|
+
const parsed = parseScopeString(row.scope);
|
|
215
|
+
if (parsed.type !== "qualified" || !bare.has(parsed.repo)) return;
|
|
216
|
+
const other = bare.get(parsed.repo);
|
|
217
|
+
throw configError(`scoped limit at index ${index}: ${JSON.stringify(row.scope)} and the bare ${JSON.stringify(parsed.repo)} at index ${other} name the same repo; keep one form (a bare row covers every forge, a qualified row one forge): ${path}`);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** A dollar field's value, or the refusal: `parseUsdMicros`'s rules (strings recommended), named by row and field. */
|
|
222
|
+
function usdField(value, field, at, path) {
|
|
223
|
+
try {
|
|
224
|
+
return parseUsdMicros(value, field);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
throw configError(`${at}: ${error.message}: ${path}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A per-model row (version 2): `{ scope: "model:<provider>/<model>", dayUsd?, weekUsd?, monthUsd? }`. The ref follows
|
|
232
|
+
* the allowed-model list's rule (`splitModelEntry`: split at the first `/`, each half a valid id). `day`, `week`,
|
|
233
|
+
* `month` and `concurrent` are refused here: a model is not a scope a job runs IN, so a job-count cap or a
|
|
234
|
+
* concurrency limit on it would read as enforced while nothing counts it.
|
|
235
|
+
*/
|
|
236
|
+
function normalizeModelLimit(row, trimmed, at, path, version) {
|
|
237
|
+
if (version < 2) throw configError(`${at}: a "model:" row needs "version": 2 (this file says ${version}): ${path}`);
|
|
238
|
+
const ref = splitModelEntry(trimmed.slice(MODEL_SCOPE_PREFIX.length));
|
|
239
|
+
if (ref === null) throw configError(`${at}: a model row's scope must be model:<provider>/<model> (each 1 to 64 characters, no spaces): ${path}`);
|
|
240
|
+
// The runner folds model-less and overflow calls into an `other/other` usage row, so a row naming that pair
|
|
241
|
+
// could never be told apart from the fold.
|
|
242
|
+
if (`${ref.provider}/${ref.model}`.toLowerCase() === "other/other") throw configError(`${at}: model:other/other names the usage ledger's fold row, not a model: ${path}`);
|
|
243
|
+
for (const field of LIMIT_FIELDS) {
|
|
244
|
+
if (row[field] !== undefined && row[field] !== null) throw configError(`${at}: a model row carries dayUsd, weekUsd and monthUsd only (${field} is refused): ${path}`);
|
|
245
|
+
}
|
|
246
|
+
const norm = { scope: `${MODEL_SCOPE_PREFIX}${ref.provider}/${ref.model}`, day: null, week: null, month: null, concurrent: null, dayUsd: null, weekUsd: null, monthUsd: null };
|
|
247
|
+
let any = false;
|
|
248
|
+
for (const field of USD_LIMIT_FIELDS) {
|
|
249
|
+
if (row[field] === undefined || row[field] === null) continue;
|
|
250
|
+
norm[field] = formatMicros(usdField(row[field], field, at, path));
|
|
251
|
+
any = true;
|
|
252
|
+
}
|
|
253
|
+
if (!any) throw configError(`${at}: a model row needs at least one of dayUsd, weekUsd, monthUsd: ${path}`);
|
|
254
|
+
return norm;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function normalizeLimit(row, index, path, version = SCOPED_LIMITS_VERSION) {
|
|
116
258
|
const at = `scoped limit at index ${index}`;
|
|
117
259
|
if (row === null || typeof row !== "object" || Array.isArray(row)) {
|
|
118
260
|
throw configError(`${at}: must be an object: ${path}`);
|
|
119
261
|
}
|
|
120
262
|
if (!isNonEmptyString(row.scope)) throw configError(`${at}: scope must be a non-empty string: ${path}`);
|
|
121
263
|
const trimmed = row.scope.trim().normalize("NFC"); // the same NFC canonicalScope applies job-side
|
|
264
|
+
if (isModelScope(trimmed)) return normalizeModelLimit(row, trimmed, at, path, version);
|
|
265
|
+
// A NEAR MISS of a reserved prefix is refused, never read as a repo or folder row (PR #549's review): a
|
|
266
|
+
// mistyped `Model:openai/x`, `models:...` or `model :...` would otherwise parse as a repo scope no job ever has,
|
|
267
|
+
// a dollar cap that governs nothing while the file reads as capping a model.
|
|
268
|
+
if (MODEL_NEAR_MISS.test(trimmed)) throw configError(`${at}: a model row's scope is written exactly model:<provider>/<model> (lowercase "model", no space before the colon): ${path}`);
|
|
269
|
+
// Issue #499 part B: `project:<id>` is a project row. Exactly that form: a near miss (`Project:shop`, `projects:shop`,
|
|
270
|
+
// `project :shop`) or a malformed id is refused, never read as a repo row no job has, for the model row's reason.
|
|
271
|
+
if (PROJECT_NEAR_MISS.test(trimmed)) {
|
|
272
|
+
const id = trimmed.startsWith(PROJECT_SCOPE_PREFIX) ? trimmed.slice(PROJECT_SCOPE_PREFIX.length) : null;
|
|
273
|
+
if (id === null) throw configError(`${at}: a project row's scope is written exactly project:<id> (lowercase "project", no space before the colon): ${path}`);
|
|
274
|
+
if (!isProjectId(id)) throw configError(`${at}: a project row's id must match ${PROJECT_ID_RE.source} (the id in projects.json): ${path}`);
|
|
275
|
+
}
|
|
122
276
|
if (trimmed === "*") {
|
|
123
277
|
// "*" as ONE shared counter is redundant with the global caps, so the only useful reading is a
|
|
124
278
|
// per-scope default -- the OPPOSITE of what "*" means one file over (pause-windows: one rule
|
|
@@ -131,19 +285,42 @@ function normalizeLimit(row, index, path) {
|
|
|
131
285
|
// nothing, and a silently inert money limit is the failure class this repo refuses outright.
|
|
132
286
|
throw configError(`${at}: scopes match exactly; a scope containing "*" is refused (no globs): ${path}`);
|
|
133
287
|
}
|
|
288
|
+
// Issue #498: `<forge kind>:<repo>` names one forge's repo; an unknown `<word>:` prefix is refused naming the kinds.
|
|
289
|
+
// A project row is not a scope string a job carries, so the scope grammar does not apply to it.
|
|
290
|
+
let form = { type: "project" };
|
|
291
|
+
if (!isProjectScope(trimmed)) {
|
|
292
|
+
try {
|
|
293
|
+
form = parseScopeString(trimmed);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
throw configError(`${at}: ${error.message}: ${path}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (form.type === "project" && version < 2) throw configError(`${at}: a project row needs "version": 2 (this file says ${version}), so a build that predates project rows refuses the file rather than reading an inert repo string: ${path}`);
|
|
299
|
+
if (form.type === "qualified" && version < 2) throw configError(`${at}: a forge-qualified scope needs "version": 2 (this file says ${version}), so a build that predates it refuses the file rather than reading an inert repo string: ${path}`);
|
|
134
300
|
const norm = {
|
|
135
301
|
// An absolute path is stored resolved so a `/srv/site/` row governs `/srv/site` jobs -- the same
|
|
136
302
|
// collapse canonicalScope applies on the job side. isAbsolute is PLATFORM-NATIVE on purpose, so a
|
|
137
303
|
// foreign-platform row (a windows drive path on a POSIX worker) stays verbatim and is inert here;
|
|
138
304
|
// the doctor's unreferenced-scope advisory names it. Resolving it instead would "work" only by
|
|
139
305
|
// both sides mangling into the same cwd-prefixed string -- a match by accident, not by contract.
|
|
140
|
-
scope: isAbsolute(trimmed) ? resolve(trimmed) : trimmed,
|
|
306
|
+
scope: isProjectScope(trimmed) ? trimmed : form.type === "qualified" ? `${form.kind}:${form.repo}` : isAbsolute(trimmed) ? resolve(trimmed) : trimmed,
|
|
141
307
|
day: null,
|
|
142
308
|
week: null,
|
|
143
309
|
month: null,
|
|
144
310
|
concurrent: null,
|
|
311
|
+
// Version 2's dollar windows, each the canonical decimal string (`formatMicros`), so the admin's
|
|
312
|
+
// read-modify-write writes back exactly what the parser accepts. `dollarCapsFor` turns them into integers.
|
|
313
|
+
// Only in a version 2 file's rows: a version 1 file reads into exactly the five-key literal it always did.
|
|
314
|
+
...(version >= 2 ? { dayUsd: null, weekUsd: null, monthUsd: null } : {}),
|
|
145
315
|
};
|
|
146
316
|
let any = false;
|
|
317
|
+
for (const field of USD_LIMIT_FIELDS) {
|
|
318
|
+
const value = row[field];
|
|
319
|
+
if (value === undefined || value === null) continue;
|
|
320
|
+
if (version < 2) throw configError(`${at}: ${field} needs "version": 2 (this file says ${version}): ${path}`);
|
|
321
|
+
norm[field] = formatMicros(usdField(value, field, at, path));
|
|
322
|
+
any = true;
|
|
323
|
+
}
|
|
147
324
|
for (const field of LIMIT_FIELDS) {
|
|
148
325
|
const value = row[field];
|
|
149
326
|
// Absent-or-null (subscriptions.mjs's rule): null is the normalizer's OWN output for an unset
|
|
@@ -161,7 +338,7 @@ function normalizeLimit(row, index, path) {
|
|
|
161
338
|
any = true;
|
|
162
339
|
}
|
|
163
340
|
if (!any) {
|
|
164
|
-
throw configError(`${at}: at least one of day, week, month, concurrent is required (a row that limits nothing is a row an operator sets and then trusts): ${path}`);
|
|
341
|
+
throw configError(`${at}: at least one of day, week, month, concurrent${version >= 2 ? ", dayUsd, weekUsd, monthUsd" : ""} is required (a row that limits nothing is a row an operator sets and then trusts): ${path}`);
|
|
165
342
|
}
|
|
166
343
|
return norm;
|
|
167
344
|
}
|
|
@@ -179,24 +356,238 @@ export function loadScopedLimits(config, { readFileSync = fsReadFileSync, exists
|
|
|
179
356
|
}
|
|
180
357
|
|
|
181
358
|
/**
|
|
182
|
-
* The
|
|
183
|
-
*
|
|
359
|
+
* The row that applies to this job, or null. Exact string equality only -- the pause matcher's semantics minus
|
|
360
|
+
* its "*" (refused above). A forge job matches the row equal to its `qualifiedScopeOf` first (`github:acme/web`),
|
|
361
|
+
* then the row equal to its canonical bare repo (`acme/web`); a local job matches its resolved folder. With
|
|
362
|
+
* duplicates refused and a bare row beside a qualified row for one repo refused too, at most one of the two can
|
|
363
|
+
* exist, so the order is not a precedence ladder: it only says where to look.
|
|
364
|
+
*/
|
|
365
|
+
export function limitFor(limits, job) {
|
|
366
|
+
if (!Array.isArray(limits)) return null;
|
|
367
|
+
// A model row is never a job's scope: it caps a model wherever it runs (`modelDollarRows`). Nor is a project row: it
|
|
368
|
+
// applies through the job's project (`projectRowFor`), never because a scope string happens to equal it.
|
|
369
|
+
const find = (scope) => (isNonEmptyString(scope) ? limits.find((l) => l.scope === scope && !isGroupScope(l.scope)) ?? null : null);
|
|
370
|
+
if (job?.kind !== "local") {
|
|
371
|
+
const qualified = find(qualifiedScopeOf(job));
|
|
372
|
+
if (qualified !== null) return qualified;
|
|
373
|
+
}
|
|
374
|
+
return find(canonicalScope(job));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The scope every per-scope KEY of this job is built from (issue #498): the matched row's scope, or the job's
|
|
379
|
+
* canonical scope when no row matches. The in-process slot, the fleet lease (`slot:s:<hash16>`), and through
|
|
380
|
+
* `budgetCapsFor`/`dollarCapsFor` the job-count and dollar windows all hash this one string. Keyed by the ROW, never
|
|
381
|
+
* the job: a bare `acme/web` row then keeps the exact key it had before qualified scopes existed (its counts carry
|
|
382
|
+
* over, and the admin, which recomputes keys from rows, keeps reading them), and it stays ONE shared cap across
|
|
383
|
+
* forges as written. The boot sweeper already hashes row scopes, so the gate and the sweeper are one rule.
|
|
184
384
|
*/
|
|
185
|
-
export function
|
|
186
|
-
|
|
187
|
-
return limits.find((l) => l.scope === scope) ?? null;
|
|
385
|
+
export function rowScopeFor(job, limits) {
|
|
386
|
+
return limitFor(limits, job)?.scope ?? canonicalScope(job);
|
|
188
387
|
}
|
|
189
388
|
|
|
190
389
|
/**
|
|
191
390
|
* The scoped budget windows this job reserves against, or null when nothing applies (no row for the
|
|
192
|
-
* scope, or the row is concurrency-only). The returned `scope` is
|
|
193
|
-
* spelling-stable. Shaped like the global `caps` object so
|
|
391
|
+
* scope, or the row is concurrency-only). The returned `scope` is the MATCHED ROW's (issue #498), so the redis
|
|
392
|
+
* counters are spelling-stable and a bare row keeps its key. Shaped like the global `caps` object so
|
|
393
|
+
* `reserveBudget` consumes it unchanged.
|
|
194
394
|
*/
|
|
195
395
|
export function budgetCapsFor(job, limits) {
|
|
196
|
-
const
|
|
197
|
-
const row = limitFor(limits, scope);
|
|
396
|
+
const row = limitFor(limits, job);
|
|
198
397
|
if (!row || (row.day === null && row.week === null && row.month === null)) return null;
|
|
199
|
-
return { scope, caps: { day: row.day, week: row.week, month: row.month } };
|
|
398
|
+
return { scope: row.scope, caps: { day: row.day, week: row.week, month: row.month } };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The project row of this project id, or null (no id, or no row for it). A job's project is resolved ONCE at pickup
|
|
403
|
+
* (`projectOf`, projects.mjs), and every project-row consumer below takes that id, never the job: the gate, the ledgers
|
|
404
|
+
* and the record then agree for the attempt whatever an operator does to projects.json mid-run.
|
|
405
|
+
*/
|
|
406
|
+
export function projectRowFor(limits, projectId) {
|
|
407
|
+
if (!Array.isArray(limits) || !isProjectId(projectId)) return null;
|
|
408
|
+
const scope = projectScope(projectId);
|
|
409
|
+
return limits.find((l) => l?.scope === scope) ?? null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The project rows whose id is not a project in `projects` (the parsed projects.json), as `[{ index, id }]` in file
|
|
414
|
+
* order. Such a row caps nothing (no job can belong to a project that does not exist), so it is a refusal wherever the
|
|
415
|
+
* two files meet: at boot, on a live reload of either file, and in the admin's write (issue #499 part B).
|
|
416
|
+
*/
|
|
417
|
+
export function danglingProjectRows(limits, projects) {
|
|
418
|
+
const ids = new Set((Array.isArray(projects) ? projects : []).map((p) => p?.id));
|
|
419
|
+
const out = [];
|
|
420
|
+
(Array.isArray(limits) ? limits : []).forEach((row, index) => {
|
|
421
|
+
if (!isProjectScope(row?.scope)) return;
|
|
422
|
+
const id = row.scope.slice(PROJECT_SCOPE_PREFIX.length);
|
|
423
|
+
if (!ids.has(id)) out.push({ index, id });
|
|
424
|
+
});
|
|
425
|
+
return out;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Refuse a limits list that names a project `projects` does not have, naming each such row's index and id (ids are
|
|
430
|
+
* charset-checked, never a path or a name) and BOTH files: the row lives in the scoped-limits file (`limitsPath`), the
|
|
431
|
+
* missing id in the projects file (`projectsPath`, null when PI_PROJECTS_FILE is unset), so an operator reading a
|
|
432
|
+
* refused projects.json edit looks for the index in the right file. Pure: the caller decides whether that is a boot
|
|
433
|
+
* refusal or a kept last-good copy.
|
|
434
|
+
*/
|
|
435
|
+
export function checkProjectRows(limits, projects, limitsPath, projectsPath = null) {
|
|
436
|
+
const dangling = danglingProjectRows(limits, projects);
|
|
437
|
+
if (dangling.length === 0) return;
|
|
438
|
+
const rows = dangling.map((d) => `index ${d.index} ("${PROJECT_SCOPE_PREFIX}${d.id}")`).join(", ");
|
|
439
|
+
const where = projectsPath ? `the projects file ${projectsPath}` : "the projects file (PI_PROJECTS_FILE is unset, so there are no projects)";
|
|
440
|
+
throw configError(`scoped-limits row(s) at ${rows} in ${limitsPath} name a project that is not in ${where}; add the project there first, or remove the row`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** The refusal reason of a full repo or folder job-count window. */
|
|
444
|
+
export const SCOPE_CAP_REASON = "scope-cap";
|
|
445
|
+
/** The refusal reason of a full project job-count window (issue #499 part B). */
|
|
446
|
+
export const PROJECT_CAP_REASON = "project-cap";
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* THE ordered job-count ledgers of one job's scoped rows (issue #499 part B), as `[{ scope, keyPrefix, caps, reason }]`:
|
|
450
|
+
* its repo or folder row, then its project's row. The processor appends the global ledger last and reserves them in
|
|
451
|
+
* this order through ONE helper (budget.mjs `reserveLedgers`), and gives them back in reverse (`releaseLedgers`).
|
|
452
|
+
*
|
|
453
|
+
* Narrowest first, for the reason the scoped ledger always reserved before the global one: a refusal by a narrow
|
|
454
|
+
* ledger never consumes a slot in a wider one, so a noisy repo cannot drain its project's day, and a full project
|
|
455
|
+
* cannot drain the deployment's. A row with no day, week or month (concurrency or dollars only) is no ledger here.
|
|
456
|
+
*
|
|
457
|
+
* `projectId` is the id resolved at pickup (`projectOf`), or null. One list instead of one hand-built pair per
|
|
458
|
+
* call site: every release site in the processor walks this list, so a ledger added here cannot be forgotten there.
|
|
459
|
+
*/
|
|
460
|
+
export function scopedLedgers(job, limits, projectId) {
|
|
461
|
+
const out = [];
|
|
462
|
+
const scoped = budgetCapsFor(job, limits);
|
|
463
|
+
if (scoped) out.push({ scope: scoped.scope, keyPrefix: scopeKeyPrefix(scoped.scope), caps: scoped.caps, reason: SCOPE_CAP_REASON });
|
|
464
|
+
const row = projectRowFor(limits, projectId);
|
|
465
|
+
if (row && (row.day !== null || row.week !== null || row.month !== null)) {
|
|
466
|
+
out.push({ scope: row.scope, keyPrefix: scopeKeyPrefix(row.scope), caps: { day: row.day, week: row.week, month: row.month }, reason: PROJECT_CAP_REASON });
|
|
467
|
+
}
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** A row's dollar windows as integer micro-dollars `{ day, week, month }`, each null when unset, or null when none is. */
|
|
472
|
+
function usdCaps(row) {
|
|
473
|
+
const caps = {};
|
|
474
|
+
for (const [field, name] of [["dayUsd", "day"], ["weekUsd", "week"], ["monthUsd", "month"]]) {
|
|
475
|
+
caps[name] = row?.[field] === null || row?.[field] === undefined ? null : parseUsdMicros(row[field], field);
|
|
476
|
+
}
|
|
477
|
+
return caps.day === null && caps.week === null && caps.month === null ? null : caps;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* The repo or folder dollar windows this job reserves in (issue #501 part 5), or null when its scope's row has none
|
|
482
|
+
* (or there is no row). Ledger-shaped for `reserveDollars`: `{ scope, keyPrefix, caps }`, the caps in integer
|
|
483
|
+
* micro-dollars and `keyPrefix` `budget:usd:s:<hash16>` of the same MATCHED ROW scope the job-count windows hash
|
|
484
|
+
* (issue #498). A sibling of `budgetCapsFor`: the job-count and dollar windows of one row are separate ledgers.
|
|
485
|
+
*/
|
|
486
|
+
export function dollarCapsFor(job, limits) {
|
|
487
|
+
const row = limitFor(limits, job);
|
|
488
|
+
const caps = row ? usdCaps(row) : null;
|
|
489
|
+
return caps === null ? null : { scope: row.scope, keyPrefix: scopeDollarKeyPrefix(row.scope), caps };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* The project dollar windows this job reserves in (issue #499 part B), or null when its project (resolved at pickup) has
|
|
494
|
+
* no row or the row has no dollar window. Ledger-shaped like `dollarCapsFor`, with `keyPrefix`
|
|
495
|
+
* `scopeDollarKeyPrefix("project:<id>")`: the project row's own scope, the rule every row's key follows, so no second
|
|
496
|
+
* dollar keyspace exists for projects.
|
|
497
|
+
*/
|
|
498
|
+
export function projectDollarCapsFor(limits, projectId) {
|
|
499
|
+
const row = projectRowFor(limits, projectId);
|
|
500
|
+
const caps = row ? usdCaps(row) : null;
|
|
501
|
+
return caps === null ? null : { scope: row.scope, keyPrefix: scopeDollarKeyPrefix(row.scope), caps };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* The model dollar windows this job reserves in (issue #502 part 6), as ledgers `{ ref, keyPrefix, caps }` in file
|
|
506
|
+
* order, where `ref` is the row's lowercased `provider/model`. `models` is the job's EFFECTIVE allowed-model list:
|
|
507
|
+
* - a list reserves in the rows of its listed models, compared ignoring case (the ledger lowercases ids, so the
|
|
508
|
+
* row and the usage row it settles from meet in lowercase);
|
|
509
|
+
* - no list (`null` or `undefined`) reserves in EVERY model row. An unrestricted job may switch to any model
|
|
510
|
+
* mid-run, so it could spend in any of them; reserving in none would let it run up a model's window unseen.
|
|
511
|
+
* This fails closed: an unrestricted job is refused when any model window is full, and the remedy is a list.
|
|
512
|
+
* A job that reserves nothing at all (the zero-reservation rule, the processor's) reserves in none of these either.
|
|
513
|
+
*/
|
|
514
|
+
export function modelDollarRows(limits, models) {
|
|
515
|
+
if (!Array.isArray(limits)) return [];
|
|
516
|
+
const rows = limits.filter((l) => isModelScope(l?.scope));
|
|
517
|
+
let wanted = null;
|
|
518
|
+
if (Array.isArray(models)) wanted = new Set(models.filter((m) => typeof m === "string").map((m) => m.toLowerCase()));
|
|
519
|
+
const out = [];
|
|
520
|
+
for (const row of rows) {
|
|
521
|
+
const ref = row.scope.slice(MODEL_SCOPE_PREFIX.length).toLowerCase();
|
|
522
|
+
if (wanted !== null && !wanted.has(ref)) continue;
|
|
523
|
+
const caps = usdCaps(row);
|
|
524
|
+
if (caps !== null) out.push({ ref, keyPrefix: modelDollarKeyPrefix(ref), caps });
|
|
525
|
+
}
|
|
526
|
+
return out;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** A row's kind for the dollar advisories: `model`, `project` (issue #499 part B) or `scope` (a repo or folder). */
|
|
530
|
+
function rowKind(scope) {
|
|
531
|
+
return isModelScope(scope) ? "model" : isProjectScope(scope) ? "project" : "scope";
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* The rows that carry a dollar window, as `[{ index, kind }]` (`kind` is `scope`, `project` or `model`), when the deployment has
|
|
536
|
+
* NO per-job cap (`deploymentMaxCostUsd` null or undefined: env and overlay merged by the caller), else `[]` (PR #549's
|
|
537
|
+
* review). Such a row refuses every job it applies to as `config-refused` unless the job's trigger sets its own
|
|
538
|
+
* `run.maxCostUsd`, so the worker and doctor WARN, never refuse: a trigger may legitimately supply the cap. Index and
|
|
539
|
+
* kind only, never the scope string (a folder scope is a host path).
|
|
540
|
+
*/
|
|
541
|
+
export function dollarRowsWithoutCap(limits, deploymentMaxCostUsd) {
|
|
542
|
+
if (deploymentMaxCostUsd !== null && deploymentMaxCostUsd !== undefined) return [];
|
|
543
|
+
const out = [];
|
|
544
|
+
(limits ?? []).forEach((row, index) => {
|
|
545
|
+
if (USD_LIMIT_FIELDS.some((f) => row?.[f] !== null && row?.[f] !== undefined)) out.push({ index, kind: rowKind(row.scope) });
|
|
546
|
+
});
|
|
547
|
+
return out;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* The windows of dollar rows whose cap is BELOW the deployment's per-job cap, as `[{ index, kind, window }]` (PR
|
|
552
|
+
* #549's review). A job reserves its whole per-job cap, so such a window refuses every job it applies to, every time,
|
|
553
|
+
* until the cap is raised or the per-job cap lowered (a trigger's smaller `run.maxCostUsd` still fits). Doctor names
|
|
554
|
+
* them; `[]` when no deployment cap is set or none is below it.
|
|
555
|
+
*/
|
|
556
|
+
export function dollarRowsBelowJobCap(limits, deploymentMaxCostUsd) {
|
|
557
|
+
if (deploymentMaxCostUsd === null || deploymentMaxCostUsd === undefined) return [];
|
|
558
|
+
const jobCap = parseUsdMicros(deploymentMaxCostUsd, "maxCostUsd");
|
|
559
|
+
const out = [];
|
|
560
|
+
(limits ?? []).forEach((row, index) => {
|
|
561
|
+
for (const [field, window] of [["dayUsd", "day"], ["weekUsd", "week"], ["monthUsd", "month"]]) {
|
|
562
|
+
if (row?.[field] === null || row?.[field] === undefined) continue;
|
|
563
|
+
if (parseUsdMicros(row[field], field) < jobCap) out.push({ index, kind: rowKind(row.scope), window });
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
return out;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* The lowest file version that expresses `rows` (normalized or as the admin builds them): 2 when any row carries a
|
|
571
|
+
* dollar field, is a model row, is a project row (issue #499 part B) or has a forge-qualified scope (issue #498), else 1.
|
|
572
|
+
* The admin writes this, so a file
|
|
573
|
+
* with bare and folder job-count rows only stays a version 1 file that an older worker still reads.
|
|
574
|
+
*/
|
|
575
|
+
export function scopedLimitsVersionFor(rows) {
|
|
576
|
+
const v2 = (rows ?? []).some((l) => {
|
|
577
|
+
const scope = typeof l?.scope === "string" ? l.scope.trim() : l?.scope;
|
|
578
|
+
return isModelScope(scope) || isProjectScope(scope) || isQualifiedScope(scope) || USD_LIMIT_FIELDS.some((f) => l?.[f] !== null && l?.[f] !== undefined);
|
|
579
|
+
});
|
|
580
|
+
return v2 ? 2 : 1;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Is this written scope forge-qualified? False for anything `parseScopeString` refuses: the parser names that. */
|
|
584
|
+
function isQualifiedScope(scope) {
|
|
585
|
+
if (typeof scope !== "string" || scope === "" || isModelScope(scope)) return false;
|
|
586
|
+
try {
|
|
587
|
+
return parseScopeString(scope).type === "qualified";
|
|
588
|
+
} catch {
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
200
591
|
}
|
|
201
592
|
|
|
202
593
|
/**
|
|
@@ -217,7 +608,7 @@ export function concurrencyFor(job, limits) {
|
|
|
217
608
|
const scope = canonicalScope(job);
|
|
218
609
|
if (scope === null) return Infinity;
|
|
219
610
|
const structural = job?.kind === "local" ? 1 : Infinity;
|
|
220
|
-
const configured = limitFor(limits,
|
|
611
|
+
const configured = limitFor(limits, job)?.concurrent ?? Infinity;
|
|
221
612
|
return Math.min(structural, configured);
|
|
222
613
|
}
|
|
223
614
|
|
|
@@ -233,8 +624,37 @@ export function concurrencyFor(job, limits) {
|
|
|
233
624
|
* accepted cost.
|
|
234
625
|
*/
|
|
235
626
|
export function scopeKeyPrefix(scope) {
|
|
236
|
-
|
|
237
|
-
|
|
627
|
+
return `budget:s:${hash16(scope)}`;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** A repo or folder's DOLLAR key prefix: `budget:usd:s:<hash16(scope)>`, the same hash as `scopeKeyPrefix`. */
|
|
631
|
+
export function scopeDollarKeyPrefix(scope) {
|
|
632
|
+
return `${SCOPE_DOLLAR_KEY_PREFIX}:${hash16(scope)}`;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* A model's DOLLAR key prefix: `budget:usd:mdl:<hash16(lowercase provider/model)>`. LOWERCASED before hashing,
|
|
637
|
+
* because the run record's ledger lowercases ids (`parseExitUsage`) and a model row settles from that ledger: one
|
|
638
|
+
* model must be one counter whatever case a row, a list or the runner spells it in. `ref` is `provider/model`,
|
|
639
|
+
* without the `model:` prefix.
|
|
640
|
+
*/
|
|
641
|
+
export function modelDollarKeyPrefix(ref) {
|
|
642
|
+
return `${MODEL_DOLLAR_KEY_PREFIX}:${hash16(String(ref).toLowerCase())}`;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** The dollar key prefix of one normalized row, scope or model: what the admin reads its counters under. */
|
|
646
|
+
export function dollarKeyPrefixFor(row) {
|
|
647
|
+
return isModelScope(row?.scope) ? modelDollarKeyPrefix(row.scope.slice(MODEL_SCOPE_PREFIX.length)) : scopeDollarKeyPrefix(row?.scope);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* The rows the boot sweeper walks for this host's stale fleet claims (`makeScopeClaimSweeper`): every row as
|
|
652
|
+
* `{ concurrent, hash }`, the hash of its ROW scope, the string the pickup gate leases under. A project row's
|
|
653
|
+
* `concurrent` (issue #499 part B) is leased under `hash16("project:<id>")` and swept under the same hash; a row with no
|
|
654
|
+
* `concurrent` carries a null count, which the sweeper skips.
|
|
655
|
+
*/
|
|
656
|
+
export function scopeClaimRows(limits) {
|
|
657
|
+
return (Array.isArray(limits) ? limits : []).map((row) => ({ concurrent: row.concurrent, hash: hash16(row.scope) }));
|
|
238
658
|
}
|
|
239
659
|
|
|
240
660
|
/**
|
package/src/service.mjs
CHANGED
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
* the rest of the config is broken.
|
|
42
42
|
*/
|
|
43
43
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
44
|
-
import {
|
|
44
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
|
|
45
45
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
46
46
|
import { connect as netConnect } from "node:net";
|
|
47
47
|
import { homedir, networkInterfaces, tmpdir, userInfo } from "node:os";
|
|
@@ -51,9 +51,13 @@ import { parseArgs } from "node:util";
|
|
|
51
51
|
import { parseBackendList, venuesOf } from "./backends.mjs";
|
|
52
52
|
import { sharedShellIgnored } from "./deployment-venue.mjs";
|
|
53
53
|
import { egressArmed, egressProxyName } from "./egress.mjs";
|
|
54
|
-
import { updateEnvFile } from "./env-file.mjs";
|
|
54
|
+
import { ENV_WRITER_FS, updateEnvFile } from "./env-file.mjs";
|
|
55
|
+
import { MODEL_ENDPOINTS_INCLUDE_NAME } from "./model-endpoints.mjs";
|
|
55
56
|
import { VALKEY_HEALTH_SCRIPT, VALKEY_PASSWORD_KEY, VALKEY_START_SCRIPT, dollarsDoubled, newValkeyPassword, valkeyPasswordDecision } from "./valkey-auth.mjs";
|
|
56
|
-
import { ALL_QUADLET_FILES, ALLOWLIST_PLACEHOLDER, NETNS_KEEPER, NETNS_KEEPER_FORMAT, judgeNetnsKeeper, keeperUnderRunningProxyHint, managerEnvRefusal, PROXY_CONF_PLACEHOLDER, QUADLET_FILES, applyStack, decideValkey, describeAction, passwdNameFrom, readSubuidRanges, readValkeyKeys, valkeySharedOn, VALKEY_SHARED_KEY, describeRollBack, journalWrite, rollBackWrites, foreignContainerRefusal, foreignContainers, lingerNote, planStack, proxyConfCopyPath, proxyRestartWarning, quadletDir, readLinger, readStackKeys, stackComponents, unknownContainerRefusal, userBusRefusal, valkeyEnvPath, valkeyPasswordRestartWarning, workerUnitDeps } from "./podman-stack.mjs";
|
|
57
|
+
import { ALL_QUADLET_FILES, ALLOWLIST_PLACEHOLDER, MODEL_ENDPOINTS_PLACEHOLDER, NETNS_KEEPER, NETNS_KEEPER_FORMAT, judgeNetnsKeeper, keeperUnderRunningProxyHint, managerEnvRefusal, PROXY_CONF_PLACEHOLDER, QUADLET_FILES, applyStack, decideValkey, describeAction, passwdNameFrom, readSubuidRanges, readValkeyKeys, valkeySharedOn, VALKEY_SHARED_KEY, describeRollBack, journalWrite, rollBackWrites, foreignContainerRefusal, foreignContainers, lingerNote, planStack, proxyConfCopyPath, proxyRestartWarning, quadletDir, readLinger, readStackKeys, stackComponents, unknownContainerRefusal, userBusRefusal, valkeyEnvPath, valkeyPasswordRestartWarning, workerUnitDeps } from "./podman-stack.mjs";
|
|
58
|
+
|
|
59
|
+
/** This command's default fs seam: its own calls, and every call the `.env` writer makes (`ENV_WRITER_FS`, issue #522). */
|
|
60
|
+
export const SERVICE_FS = { existsSync, mkdirSync, ...ENV_WRITER_FS };
|
|
57
61
|
|
|
58
62
|
// src/ is where this module lives in BOTH layouts (worker/src in a checkout,
|
|
59
63
|
// node_modules/@edgehero/pi-dispatch/src under npm). Deploy templates resolve one level up from it
|
|
@@ -169,6 +173,7 @@ export const TEMPLATE_PINS = {
|
|
|
169
173
|
"pi-dispatch-egress-proxy.container": [
|
|
170
174
|
`Volume=${PROXY_CONF_PLACEHOLDER}:/etc/squid/squid.conf:ro,z`, // → the account-owned COPY of the package's egress-proxy.conf (~/.config/pi-dispatch/egress-proxy.conf, podman-stack.mjs proxyConfCopyPath), because `z` cannot relabel a root-owned package file (measured)
|
|
171
175
|
`Volume=${ALLOWLIST_PLACEHOLDER}:/etc/pi-dispatch/allowlist.conf:ro,z`, // → <deployDir>/egress-allowlist.conf, the list `init` scaffolds
|
|
176
|
+
`Volume=${MODEL_ENDPOINTS_PLACEHOLDER}:/etc/pi-dispatch/model-endpoints.conf:ro,z`, // → <deployDir>/model-endpoints.conf, the include `init` scaffolds and `egress render` rewrites in place (issue #503)
|
|
172
177
|
"ContainerName=pi-dispatch-egress-proxy",
|
|
173
178
|
"Network=pi-dispatch-egress-out.network",
|
|
174
179
|
"WantedBy=default.target",
|
|
@@ -343,7 +348,7 @@ export async function runService(argv = [], deps = {}) {
|
|
|
343
348
|
// this command. An operator changes it by running the command as someone else, not by declaring it.
|
|
344
349
|
user = env.USER || userInfo().username,
|
|
345
350
|
tmp = tmpdir(),
|
|
346
|
-
fs =
|
|
351
|
+
fs = SERVICE_FS,
|
|
347
352
|
spawn = nodeSpawn,
|
|
348
353
|
out = (s) => process.stdout.write(s),
|
|
349
354
|
err = (s) => process.stderr.write(s),
|
|
@@ -1056,6 +1061,12 @@ async function stackRefusal(ctx, paths, stack) {
|
|
|
1056
1061
|
if (stack.components.proxy && !ctx.fs.existsSync(allowlist)) {
|
|
1057
1062
|
blocking.push(`the egress policy is on, and ${allowlist} does not exist: a proxy unit mounting a missing file makes Podman create a DIRECTORY there and squid fail confusingly. Run \`pi-dispatch init\` in ${ctx.deployDir} first (it never overwrites), or set PI_EGRESS=0 in .env to opt out of the policy`);
|
|
1058
1063
|
}
|
|
1064
|
+
// Issue #503: the rules include the declared model endpoints' file, and squid will not start when it is missing. Said
|
|
1065
|
+
// only beside an allowlist that exists: without one, the reason above already names `init`, which writes both.
|
|
1066
|
+
const endpointsInclude = join(ctx.deployDir, MODEL_ENDPOINTS_INCLUDE_NAME);
|
|
1067
|
+
if (stack.components.proxy && ctx.fs.existsSync(allowlist) && !ctx.fs.existsSync(endpointsInclude)) {
|
|
1068
|
+
blocking.push(`the egress policy is on, and ${endpointsInclude} does not exist: the proxy's rules include it, and squid will not start without it. Run \`pi-dispatch init\` in ${ctx.deployDir} first (it never overwrites), or set PI_EGRESS=0 in .env to opt out of the policy`);
|
|
1069
|
+
}
|
|
1059
1070
|
const bus = stack.plan.actions.length > 0 ? userBusRefusal({ env: ctx.env, user: ctx.user, euid: ctx.euid }) : null;
|
|
1060
1071
|
if (bus) blocking.push(bus);
|
|
1061
1072
|
// The manager's own XDG_RUNTIME_DIR and XDG_CONFIG_HOME (PR #463 round 3, measured): another account's makes every
|