@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/backends.mjs
CHANGED
|
@@ -699,6 +699,7 @@ const BACKENDS_TABLE = {
|
|
|
699
699
|
// Measured rootless: an `--internal` network reaches NOTHING on the host (its IP, host.containers.internal,
|
|
700
700
|
// 10.0.2.2 and the gateway all fail), and a proxy started on a NAMED bridge network under the same rootless
|
|
701
701
|
// Podman can be `network connect`ed to it (a pasta or slirp4netns one cannot: exit 125).
|
|
702
|
+
// The PROXY does reach a server on the host, a reachability fact kept apart in `HOST_ROUTES` (issue #503).
|
|
702
703
|
egress: ENFORCED,
|
|
703
704
|
// Same switch: the job and the proxy are the only endpoints on an `--internal` network. Measured, a peer on
|
|
704
705
|
// another job's network is unreachable (ENOTFOUND / ENETUNREACH).
|
|
@@ -1045,3 +1046,245 @@ export function backendRefusals({ backends = [], backendFloor = {}, egress = fal
|
|
|
1045
1046
|
|
|
1046
1047
|
return out;
|
|
1047
1048
|
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* HOW THE EGRESS PROXY REACHES A MODEL SERVER ON THE HOST (issue #503). A REACHABILITY FACT, not a security
|
|
1052
|
+
* property: nothing here is a declaration word, nothing is compared against `PI_BACKEND_FLOOR`, and no row can
|
|
1053
|
+
* make a boot or a job refuse. The job's own network is unchanged and still reaches nothing on the host but the
|
|
1054
|
+
* proxy (the `egress` entries above); these rows are about where the PROXY dials.
|
|
1055
|
+
*
|
|
1056
|
+
* Keyed by venue, each row `{ name, when, status, needs, measured }`:
|
|
1057
|
+
* - `name` is an alias (`host.docker.internal`, `host.containers.internal`) or one of three classes:
|
|
1058
|
+
* `HOST_ROUTE_OWN_ADDRESS`, `HOST_ROUTE_OTHER_MACHINE` and `HOST_ROUTE_LOOPBACK`.
|
|
1059
|
+
* - `when` is what the runtime must report for the row to apply (`hostRouteFor` says what each field is), the
|
|
1060
|
+
* version matched EXACTLY.
|
|
1061
|
+
* - `status` is `works` or `refuted` for a route to THIS host, or `reachable` for the informational
|
|
1062
|
+
* other-machine rows, which are an ordinary outbound route and never make `hostRouteFor` answer `works`.
|
|
1063
|
+
* - `measured` is the date and the runtime it was measured on.
|
|
1064
|
+
* Every row states only what the two measurement comments on issue #503 (2026-09-30 and its addendum) publish:
|
|
1065
|
+
* CONNECT tunnels through the shipped proxy image, the way pi sends. Of those, M1, M3, M4 (Podman 5.8.1), M6 and
|
|
1066
|
+
* M8 were re-run independently the same day; M5 (Docker Engine and rootful Podman) and the 4.9.3 routes were not.
|
|
1067
|
+
* One row is later: Docker Desktop's own LAN address, measured 2026-10-02 in #503's acceptance run (issue #530), a
|
|
1068
|
+
* CONNECT to 192.168.68.54:18434 with the server listening only on that address answering 200.
|
|
1069
|
+
* `docs/backends.md` generates its section from this table, and `docs/podman.md` its Podman rows.
|
|
1070
|
+
*/
|
|
1071
|
+
export const HOST_ROUTE_WORKS = "works";
|
|
1072
|
+
export const HOST_ROUTE_REFUTED = "refuted";
|
|
1073
|
+
export const HOST_ROUTE_UNMEASURED = "unmeasured";
|
|
1074
|
+
export const HOST_ROUTE_REACHABLE = "reachable";
|
|
1075
|
+
export const HOST_ROUTE_LAN = "lan";
|
|
1076
|
+
export const HOST_ROUTE_OWN_ADDRESS = "this host's own LAN address";
|
|
1077
|
+
export const HOST_ROUTE_OTHER_MACHINE = "another machine on the LAN";
|
|
1078
|
+
export const HOST_ROUTE_LOOPBACK = "a loopback or host-local address";
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* The addresses the proxy treats as this host's own loopback: `to_host_local` in deploy/egress-proxy.conf minus
|
|
1082
|
+
* 169.254.0.0/16 and fe80::/10. Those two stay OUT on purpose: Podman 5.3 and later reaches the host at
|
|
1083
|
+
* 169.254.1.2 (`host.containers.internal`, measured 2026-09-30 on 5.8.1). 10.0.2.2, slirp4netns's host alias, stays
|
|
1084
|
+
* IN. THE ONE SOURCE: `model-endpoints.mjs` builds its rendered `_local` ACL from it, its parser refuses a declared
|
|
1085
|
+
* host in it, and `hostRouteFor` refutes one, each through `isProxyLocalHost`. IPv6 entries are single addresses.
|
|
1086
|
+
*/
|
|
1087
|
+
export const PROXY_LOCAL_ADDRESSES = Object.freeze(["127.0.0.0/8", "0.0.0.0/32", "10.0.2.2/32", "::1", "::/128"]);
|
|
1088
|
+
|
|
1089
|
+
const ipv4Number = (host) => host.split(".").reduce((n, octet) => n * 256 + Number(octet), 0);
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Whether a CANONICAL host is in `PROXY_LOCAL_ADDRESSES` (`ipv4`: dotted quad; `ipv6`: compressed, lowercase, no
|
|
1093
|
+
* brackets), or a `name` that is `localhost` or `*.localhost`. Anything else, an unknown kind included, is not.
|
|
1094
|
+
*/
|
|
1095
|
+
export function isProxyLocalHost(kind, host) {
|
|
1096
|
+
if (kind === "name") return host === "localhost" || host.endsWith(".localhost");
|
|
1097
|
+
for (const entry of PROXY_LOCAL_ADDRESSES) {
|
|
1098
|
+
const [base, bits] = entry.split("/");
|
|
1099
|
+
if (kind === "ipv4" && !base.includes(":")) {
|
|
1100
|
+
const shift = 2 ** (32 - Number(bits));
|
|
1101
|
+
if (Math.floor(ipv4Number(host) / shift) === Math.floor(ipv4Number(base) / shift)) return true;
|
|
1102
|
+
}
|
|
1103
|
+
if (kind === "ipv6" && base.includes(":") && base === host) return true;
|
|
1104
|
+
}
|
|
1105
|
+
return false;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const HDI = "host.docker.internal";
|
|
1109
|
+
const HCI = "host.containers.internal";
|
|
1110
|
+
const MEASURED_DESKTOP = "2026-09-30, Docker Desktop 4.37.2 (engine 27.4.0), macOS";
|
|
1111
|
+
// Issue #530: the own-address route, measured in the #503 acceptance run on the same Desktop two days later.
|
|
1112
|
+
const MEASURED_DESKTOP_OWN = "2026-10-02, Docker Desktop 4.37.2 (engine 27.4.0), macOS";
|
|
1113
|
+
const MEASURED_ENGINE = "2026-09-30, Docker Engine 29.1.3, Ubuntu 24.04";
|
|
1114
|
+
const MEASURED_ROOTFUL = "2026-09-30, Podman 5.8.1 rootful, Fedora";
|
|
1115
|
+
const MEASURED_SLIRP = "2026-09-30, Podman 4.9.3 rootless, slirp4netns, Ubuntu 24.04";
|
|
1116
|
+
const MEASURED_PASTA = "2026-09-30, Podman 5.8.1 rootless, pasta, Fedora";
|
|
1117
|
+
const DESKTOP = { backend: "docker", desktop: true, os: "darwin", version: "27.4.0" };
|
|
1118
|
+
const ENGINE = { backend: "docker", desktop: false, version: "29.1.3" };
|
|
1119
|
+
const ROOTFUL = { backend: "podman", rootless: false, version: "5.8.1" };
|
|
1120
|
+
const SLIRP = { backend: "podman", rootless: true, helper: "slirp4netns", version: "4.9.3" };
|
|
1121
|
+
const PASTA = { backend: "podman", rootless: true, helper: "pasta", version: "5.8.1" };
|
|
1122
|
+
const BOUND = "The server is bound to that address.";
|
|
1123
|
+
const LAN = "Another machine on the LAN (192.168.5.2) answered through the proxy.";
|
|
1124
|
+
|
|
1125
|
+
const HOST_ROUTES_TABLE = {
|
|
1126
|
+
"docker-desktop": [
|
|
1127
|
+
{ name: HDI, when: DESKTOP, status: HOST_ROUTE_WORKS, needs: "Nothing to add. It reaches a server that listens on loopback only.", measured: MEASURED_DESKTOP },
|
|
1128
|
+
{ name: HOST_ROUTE_OWN_ADDRESS, when: DESKTOP, status: HOST_ROUTE_WORKS, needs: BOUND, measured: MEASURED_DESKTOP_OWN },
|
|
1129
|
+
],
|
|
1130
|
+
"docker-engine": [
|
|
1131
|
+
{ name: HDI, when: ENGINE, status: HOST_ROUTE_WORKS, needs: "`--add-host host.docker.internal:host-gateway` on the proxy gives 172.17.0.1, which answers from the proxy's own bridge, even with docker0 down. The server listens on 172.17.0.1 or 0.0.0.0. A host with UFW active is unmeasured.", measured: MEASURED_ENGINE },
|
|
1132
|
+
{ name: HCI, when: ENGINE, status: HOST_ROUTE_REFUTED, needs: "The name is not defined on Docker Engine. Declare host.docker.internal.", measured: MEASURED_ENGINE },
|
|
1133
|
+
{ name: HOST_ROUTE_OWN_ADDRESS, when: ENGINE, status: HOST_ROUTE_WORKS, needs: BOUND, measured: MEASURED_ENGINE },
|
|
1134
|
+
{ name: HOST_ROUTE_OTHER_MACHINE, when: ENGINE, status: HOST_ROUTE_REACHABLE, needs: LAN, measured: MEASURED_ENGINE },
|
|
1135
|
+
],
|
|
1136
|
+
"podman-rootful": [
|
|
1137
|
+
{ name: HCI, when: ROOTFUL, status: HOST_ROUTE_WORKS, needs: "The name is the network gateway, and the server listens on 0.0.0.0 only.", measured: MEASURED_ROOTFUL },
|
|
1138
|
+
{ name: HOST_ROUTE_OWN_ADDRESS, when: ROOTFUL, status: HOST_ROUTE_WORKS, needs: BOUND, measured: MEASURED_ROOTFUL },
|
|
1139
|
+
{ name: HOST_ROUTE_OTHER_MACHINE, when: ROOTFUL, status: HOST_ROUTE_REACHABLE, needs: LAN, measured: MEASURED_ROOTFUL },
|
|
1140
|
+
],
|
|
1141
|
+
"podman-rootless-slirp4netns": [
|
|
1142
|
+
{ name: HCI, when: SLIRP, status: HOST_ROUTE_WORKS, needs: "No flag and no containers.conf key. The name resolves to 192.168.5.15, and the server listens on its LAN address or 0.0.0.0.", measured: MEASURED_SLIRP },
|
|
1143
|
+
{ name: HOST_ROUTE_OWN_ADDRESS, when: SLIRP, status: HOST_ROUTE_WORKS, needs: BOUND, measured: MEASURED_SLIRP },
|
|
1144
|
+
{ name: HOST_ROUTE_OTHER_MACHINE, when: SLIRP, status: HOST_ROUTE_REACHABLE, needs: LAN, measured: MEASURED_SLIRP },
|
|
1145
|
+
],
|
|
1146
|
+
"podman-rootless-pasta": [
|
|
1147
|
+
{ name: HCI, when: PASTA, status: HOST_ROUTE_WORKS, needs: "No flag and no containers.conf key. The name resolves to 169.254.1.2, and the server listens on its LAN address or 0.0.0.0.", measured: MEASURED_PASTA },
|
|
1148
|
+
{ name: HOST_ROUTE_OWN_ADDRESS, when: PASTA, status: HOST_ROUTE_REFUTED, needs: "Refused under pasta. Declare host.containers.internal.", measured: MEASURED_PASTA },
|
|
1149
|
+
{ name: HOST_ROUTE_OTHER_MACHINE, when: PASTA, status: HOST_ROUTE_REACHABLE, needs: LAN, measured: MEASURED_PASTA },
|
|
1150
|
+
],
|
|
1151
|
+
"every-venue": [
|
|
1152
|
+
{ name: HOST_ROUTE_LOOPBACK, when: {}, status: HOST_ROUTE_REFUTED, needs: "From the proxy this is the proxy's own host-local address, and the proxy denies it. A server that listens on loopback only answers on Docker Desktop alone, through host.docker.internal.", measured: "2026-09-30, true on every runtime by construction, and measured on the four VM runtimes" },
|
|
1153
|
+
],
|
|
1154
|
+
};
|
|
1155
|
+
|
|
1156
|
+
/** FROZEN, rows and their `when` included, for `BACKENDS`' reason: a table a consumer could rewrite is not a table. */
|
|
1157
|
+
export const HOST_ROUTES = Object.freeze(
|
|
1158
|
+
Object.fromEntries(Object.entries(HOST_ROUTES_TABLE).map(([venue, rows]) => [venue, Object.freeze(rows.map((row) => Object.freeze({ ...row, when: Object.freeze({ ...row.when }) })))])),
|
|
1159
|
+
);
|
|
1160
|
+
|
|
1161
|
+
const ALIASES = new Set([HDI, HCI]);
|
|
1162
|
+
const HELPERS = new Set(["slirp4netns", "pasta"]);
|
|
1163
|
+
const IPV4_RE = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
|
|
1164
|
+
const LABEL_RE = /^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/;
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* A host in the form `parseModelEndpoints` stores it, as `{ kind, host }`, or null for anything else. Doctor passes
|
|
1168
|
+
* the PARSED endpoint host, so a host this refuses was never declarable: a dotted-quad IPv4 with no leading zero, a
|
|
1169
|
+
* compressed lowercase IPv6 equal to its URL form (bracketed as the parser stores it, or bare), or a lowercase DNS
|
|
1170
|
+
* name whose last label is not a number. One trailing dot on a name is dropped, as the parser drops it on a baseUrl.
|
|
1171
|
+
*/
|
|
1172
|
+
function declaredHostForm(raw) {
|
|
1173
|
+
if (typeof raw !== "string") return null;
|
|
1174
|
+
if (IPV4_RE.test(raw)) return { kind: "ipv4", host: raw };
|
|
1175
|
+
if (raw.includes(":")) {
|
|
1176
|
+
const bare = raw.startsWith("[") && raw.endsWith("]") ? raw.slice(1, -1) : raw;
|
|
1177
|
+
try {
|
|
1178
|
+
return new URL(`http://[${bare}]/`).hostname === `[${bare}]` ? { kind: "ipv6", host: bare } : null;
|
|
1179
|
+
} catch {
|
|
1180
|
+
return null;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
const name = raw.endsWith(".") ? raw.slice(0, -1) : raw;
|
|
1184
|
+
if (name.length === 0 || name.length > 253) return null;
|
|
1185
|
+
const labels = name.split(".");
|
|
1186
|
+
if (!labels.every((label) => LABEL_RE.test(label))) return null;
|
|
1187
|
+
if (/^(?:\d+|0x[0-9a-f]*)$/.test(labels[labels.length - 1])) return null;
|
|
1188
|
+
return { kind: "name", host: name };
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/** Which runtime input is missing or invalid, or null. */
|
|
1192
|
+
function runtimeInputProblem(runtime) {
|
|
1193
|
+
if (!runtime || typeof runtime !== "object") return "the runtime (an object)";
|
|
1194
|
+
if (runtime.backend !== "docker" && runtime.backend !== "podman") return 'backend ("docker" or "podman")';
|
|
1195
|
+
if (typeof runtime.version !== "string" || runtime.version.trim() === "") return "version (a string)";
|
|
1196
|
+
if (runtime.backend === "docker" && typeof runtime.desktop !== "boolean") return "desktop (a boolean, for docker)";
|
|
1197
|
+
if (runtime.backend === "docker" && runtime.desktop && typeof runtime.os !== "string") return 'os (a string such as "darwin", for Docker Desktop)';
|
|
1198
|
+
if (runtime.backend === "podman" && typeof runtime.rootless !== "boolean") return "rootless (a boolean, for podman)";
|
|
1199
|
+
if (runtime.backend === "podman" && runtime.rootless && !(typeof runtime.helper === "string" && HELPERS.has(runtime.helper.toLowerCase()))) {
|
|
1200
|
+
return 'helper ("slirp4netns" or "pasta", for rootless podman)';
|
|
1201
|
+
}
|
|
1202
|
+
return null;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function rowApplies(when, runtime) {
|
|
1206
|
+
if (when.backend === undefined) return true;
|
|
1207
|
+
if (when.backend !== runtime.backend || when.version !== runtime.version.trim().replace(/^v/, "")) return false;
|
|
1208
|
+
if (when.backend === "docker") return when.desktop === runtime.desktop && (!when.desktop || when.os === runtime.os);
|
|
1209
|
+
if (when.rootless !== runtime.rootless) return false;
|
|
1210
|
+
return !when.rootless || when.helper === runtime.helper.toLowerCase();
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/** The runtimes another machine on the LAN was measured reachable on, from the informational rows. */
|
|
1214
|
+
const LAN_MEASURED_ON = Object.values(HOST_ROUTES_TABLE)
|
|
1215
|
+
.flat()
|
|
1216
|
+
.filter((row) => row.name === HOST_ROUTE_OTHER_MACHINE)
|
|
1217
|
+
.map((row) => row.measured.slice("2026-09-30, ".length))
|
|
1218
|
+
.join("; ");
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* What the egress proxy on this runtime does with `endpointHost`, as `{ status, sentence }`. Pure: it reads no system.
|
|
1222
|
+
* `endpointHost` is a host as `parseModelEndpoints` stored it. The runtime is what the runtime itself reports:
|
|
1223
|
+
* - `backend`: `"docker"` or `"podman"`, the RUNTIME and not the venue name, so rootful Podman reached through its
|
|
1224
|
+
* Docker API is `"podman"`;
|
|
1225
|
+
* - `version`: a string, matched exactly (a leading `v` is ignored, a pre-release suffix is another version);
|
|
1226
|
+
* - `desktop`: a boolean, for docker, and `os` (`process.platform`'s word) for Docker Desktop;
|
|
1227
|
+
* - `rootless`: a boolean, for podman, and `helper`, `"slirp4netns"` or `"pasta"`, for rootless podman;
|
|
1228
|
+
* - `hostAddresses`: optional, this host's own IPv4 addresses, each a plain dotted quad.
|
|
1229
|
+
* The rule, and the only answers:
|
|
1230
|
+
* 1. a host not in a declared form, or a missing runtime field: `unmeasured`, naming which;
|
|
1231
|
+
* 2. an address in `PROXY_LOCAL_ADDRESSES`, or `localhost`: `refuted`, on every runtime;
|
|
1232
|
+
* 3. one of the two alias names: its measured row, else `unmeasured`;
|
|
1233
|
+
* 4. an IPv6 literal, a link-local IPv4 (pasta's host is 169.254.1.2), or an IPv4 literal with no `hostAddresses`:
|
|
1234
|
+
* `unmeasured`, since each may be this host's own; `hostAddresses` given but empty, or with an entry that is not
|
|
1235
|
+
* a plain IPv4, is `unmeasured` naming it, whatever the host;
|
|
1236
|
+
* 5. an IPv4 literal in `hostAddresses`: the own-address row, else `unmeasured`;
|
|
1237
|
+
* 6. anything else, another IPv4 or any other DNS name: `lan`, an ordinary outbound route that is not a route to
|
|
1238
|
+
* this host, and never `works`.
|
|
1239
|
+
*/
|
|
1240
|
+
/**
|
|
1241
|
+
* Whether a declared endpoint host names a DIFFERENT server on every machine that declares it (issue #503): the two
|
|
1242
|
+
* host alias NAMES, each resolving to the machine the proxy runs on. Takes the host as `parseModelEndpoints` stores
|
|
1243
|
+
* it; one trailing dot is read as the name without it, as the baseUrl derivation reads it.
|
|
1244
|
+
*
|
|
1245
|
+
* The slot lease reads it: a fleet key built from the endpoint's id would make two Macs that each declare
|
|
1246
|
+
* `host.docker.internal:11434` share one bound for two servers (measured on PR #518's gate), so such an endpoint is
|
|
1247
|
+
* bounded per host only, which is exact for a server only that host can reach.
|
|
1248
|
+
*
|
|
1249
|
+
* NAMES ONLY, never an address, link-local included (PR #518's second gate). A link-local address is unique on its
|
|
1250
|
+
* LINK, not on its machine: several hosts on one Thunderbolt bridge reach ONE neighbour at 169.254.x.y, and each
|
|
1251
|
+
* skipping the fleet lease would put N times `slots` on that server. Every literal takes the fleet lease, so the
|
|
1252
|
+
* worst case is a bound tighter than needed (pasta's host address shared by name), never an oversubscribed server.
|
|
1253
|
+
*/
|
|
1254
|
+
export function isPerMachineHost(endpointHost) {
|
|
1255
|
+
const target = declaredHostForm(endpointHost);
|
|
1256
|
+
return target !== null && target.kind === "name" && ALIASES.has(target.host);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
export function hostRouteFor(runtime, endpointHost) {
|
|
1260
|
+
const unmeasured = (sentence) => ({ status: HOST_ROUTE_UNMEASURED, sentence });
|
|
1261
|
+
const target = declaredHostForm(endpointHost);
|
|
1262
|
+
if (!target) return unmeasured(`The endpoint host ${JSON.stringify(endpointHost ?? null)} is not a declared host form.`);
|
|
1263
|
+
const verdict = (row) => ({ status: row.status, sentence: `${target.host} ${row.status === HOST_ROUTE_WORKS ? "works" : "does not work"} from the proxy (measured ${row.measured}). ${row.needs}` });
|
|
1264
|
+
if (isProxyLocalHost(target.kind, target.host)) return verdict(HOST_ROUTES["every-venue"][0]);
|
|
1265
|
+
const problem = runtimeInputProblem(runtime);
|
|
1266
|
+
if (problem) return unmeasured(`The runtime's ${problem} is missing or invalid, so no measured route applies to ${target.host}.`);
|
|
1267
|
+
if (runtime.hostAddresses !== undefined) {
|
|
1268
|
+
if (!Array.isArray(runtime.hostAddresses) || runtime.hostAddresses.length === 0) return unmeasured(`No host addresses were given (hostAddresses must list this host's IPv4 addresses), so no route to ${target.host} is judged.`);
|
|
1269
|
+
const bad = runtime.hostAddresses.find((a) => typeof a !== "string" || !IPV4_RE.test(a));
|
|
1270
|
+
if (bad !== undefined) return unmeasured(`The host address ${JSON.stringify(bad)} is not a plain IPv4 address, so no route to ${target.host} is judged.`);
|
|
1271
|
+
}
|
|
1272
|
+
const rows = Object.values(HOST_ROUTES).flat().filter((row) => rowApplies(row.when, runtime));
|
|
1273
|
+
const what = [runtime.backend, runtime.version, runtime.backend === "podman" ? (runtime.rootless ? `rootless ${runtime.helper}` : "rootful") : runtime.desktop ? `desktop ${runtime.os}` : "engine"].join(" ");
|
|
1274
|
+
const none = () => unmeasured(`No route from the proxy to ${target.host} was measured on ${what}. docs/backends.md lists the measured ones.`);
|
|
1275
|
+
if (target.kind === "name" && ALIASES.has(target.host)) {
|
|
1276
|
+
const row = rows.find((r) => r.name === target.host);
|
|
1277
|
+
return row ? verdict(row) : none();
|
|
1278
|
+
}
|
|
1279
|
+
if (target.kind === "ipv6") return unmeasured(`${target.host} is an IPv6 literal: no IPv6 route was measured, and hostAddresses holds IPv4 only.`);
|
|
1280
|
+
if (target.kind === "ipv4") {
|
|
1281
|
+
if (runtime.hostAddresses === undefined) return unmeasured(`No host addresses were given, so ${target.host} may be this host's own and no route is judged.`);
|
|
1282
|
+
// pasta reaches this host at 169.254.1.2, an address the host does not hold, so link-local is never "not this host".
|
|
1283
|
+
if (target.host.startsWith("169.254.")) return unmeasured(`${target.host} is link-local, which may be this host as a rootless helper maps it; only the alias name was measured.`);
|
|
1284
|
+
if (runtime.hostAddresses.includes(target.host)) {
|
|
1285
|
+
const row = rows.find((r) => r.name === HOST_ROUTE_OWN_ADDRESS);
|
|
1286
|
+
return row ? verdict(row) : none();
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
return { status: HOST_ROUTE_LAN, sentence: `${target.host} is not a route to this host: an ordinary outbound route through the proxy. Another machine on the LAN was measured reachable (IPv4, 192.168.5.2) on ${LAN_MEASURED_ON}.` };
|
|
1290
|
+
}
|
package/src/budget.mjs
CHANGED
|
@@ -54,10 +54,11 @@ export function tokenDayKey(now = new Date(), prefix = "budget") {
|
|
|
54
54
|
|
|
55
55
|
// Each window's TTL outlives its bucket with slack, so a stale counter is reclaimed shortly after the
|
|
56
56
|
// window rolls over. Set once, on first reservation only (reserved === 1), so a busy window cannot push
|
|
57
|
-
// its own expiry forward indefinitely.
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
const
|
|
57
|
+
// its own expiry forward indefinitely. EXPORTED for the dollar windows (dollar-budget.mjs, issue #501), so a
|
|
58
|
+
// dollar counter and a job-count counter for the same window live and expire on the same terms.
|
|
59
|
+
export const DAY_TTL_SECONDS = 2 * 24 * 60 * 60; // outlives the UTC day
|
|
60
|
+
export const WEEK_TTL_SECONDS = 9 * 24 * 60 * 60; // outlives the Mon-Sun week
|
|
61
|
+
export const MONTH_TTL_SECONDS = 40 * 24 * 60 * 60; // outlives the longest month
|
|
61
62
|
|
|
62
63
|
/**
|
|
63
64
|
* Classify one window's reserved count against its cap and the soft-hold band. PURE, and exported so the
|
|
@@ -140,6 +141,41 @@ export async function releaseBudget(redis, { caps, now = new Date(), keyPrefix =
|
|
|
140
141
|
}
|
|
141
142
|
}
|
|
142
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Reserve one slot in each job-count LEDGER, in order (issue #499 part B, DES-SCOPED-LIMITS-AND-FOLDER-MUTEX). A ledger
|
|
146
|
+
* is `{ scope, keyPrefix, caps, softHoldPct?, reason }`: the repo or folder row's, the project row's, then the global
|
|
147
|
+
* one (`scope` null, the default key prefix, the soft-hold band), as `scopedLedgers` and the processor build them.
|
|
148
|
+
*
|
|
149
|
+
* Each ledger that ADMITS is pushed onto `held`, the caller's list of reservations still standing, the moment its
|
|
150
|
+
* reservation lands. The first ledger that refuses stops the walk and is returned as `refusedBy` with its result; it is
|
|
151
|
+
* NOT pushed, because a refused reservation still counts (`reserveBudget`) and is never given back. The ledgers before it
|
|
152
|
+
* are in `held`, and the caller gives them back with `releaseLedgers`: a ledger that did not issue the refusal gives
|
|
153
|
+
* back, the rule the scoped ledger has followed since issue #242. A Valkey fault mid-walk throws with `held` exact.
|
|
154
|
+
*/
|
|
155
|
+
export async function reserveLedgers(redis, ledgers, held, { now = new Date() } = {}) {
|
|
156
|
+
for (const ledger of ledgers ?? []) {
|
|
157
|
+
const result = await reserveBudget(redis, { caps: ledger.caps, softHoldPct: ledger.softHoldPct ?? null, now, ...(ledger.keyPrefix ? { keyPrefix: ledger.keyPrefix } : {}) });
|
|
158
|
+
if (!result.allowed) return { allowed: false, refusedBy: ledger, result };
|
|
159
|
+
held.push(ledger);
|
|
160
|
+
}
|
|
161
|
+
return { allowed: true, refusedBy: null, result: null };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Give back every reservation in `held`, LAST FIRST (the reverse of `reserveLedgers`), removing each from `held` once
|
|
166
|
+
* its release has landed. THE one release of job-count slots: every refund path in the processor calls this with the
|
|
167
|
+
* same list, so no path can give back one ledger and forget another, and none can give one back twice (a second call
|
|
168
|
+
* finds `held` empty; `releaseBudget` is a floorless DECR). A Valkey fault throws with the unreleased ledgers still in
|
|
169
|
+
* `held`, so the caller's record of what stays reserved is exact.
|
|
170
|
+
*/
|
|
171
|
+
export async function releaseLedgers(redis, held, { now = new Date() } = {}) {
|
|
172
|
+
while (held.length > 0) {
|
|
173
|
+
const ledger = held[held.length - 1];
|
|
174
|
+
await releaseBudget(redis, { caps: ledger.caps, now, ...(ledger.keyPrefix ? { keyPrefix: ledger.keyPrefix } : {}) });
|
|
175
|
+
held.pop();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
143
179
|
/**
|
|
144
180
|
* The daily TOKEN cap check -- read-only, no increment. Returns `{ allowed, reason, spent, cap }`.
|
|
145
181
|
*
|