@edgehero/pi-dispatch 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.env.example +41 -5
  2. package/README.md +11 -5
  3. package/deploy/docker-compose.yml +12 -0
  4. package/deploy/egress-proxy.conf +28 -3
  5. package/deploy/pi-dispatch-egress-proxy.container +8 -2
  6. package/package.json +8 -1
  7. package/src/allocation.mjs +731 -0
  8. package/src/backends.mjs +243 -0
  9. package/src/budget.mjs +40 -4
  10. package/src/cli.mjs +222 -11
  11. package/src/config.mjs +126 -5
  12. package/src/daemon-facts.mjs +3 -0
  13. package/src/deployment-venue.mjs +1 -0
  14. package/src/doctor.mjs +2261 -203
  15. package/src/dollar-budget.mjs +373 -0
  16. package/src/dollar-fingerprint.mjs +83 -0
  17. package/src/egress-cli.mjs +316 -0
  18. package/src/egress-proxy-state.mjs +35 -5
  19. package/src/egress.mjs +12 -0
  20. package/src/env-allowlist.mjs +107 -6
  21. package/src/env-file.mjs +194 -25
  22. package/src/envelope.mjs +413 -0
  23. package/src/exit-code.mjs +22 -0
  24. package/src/fleet-lease.mjs +85 -25
  25. package/src/get-token.mjs +16 -5
  26. package/src/git-dirty.mjs +67 -0
  27. package/src/github-app-setup.mjs +6 -3
  28. package/src/github-host.mjs +5 -3
  29. package/src/identity.mjs +2 -1
  30. package/src/image-preflight.mjs +98 -24
  31. package/src/image-ref.mjs +37 -0
  32. package/src/import-pi.mjs +4 -2
  33. package/src/index.mjs +407 -62
  34. package/src/init.mjs +18 -0
  35. package/src/job-id.mjs +26 -3
  36. package/src/live-probes.mjs +24 -9
  37. package/src/model-catalog.mjs +297 -0
  38. package/src/model-endpoints.mjs +649 -0
  39. package/src/model-ref.mjs +151 -0
  40. package/src/models-json.mjs +262 -0
  41. package/src/money.mjs +144 -0
  42. package/src/octokit-log.mjs +65 -0
  43. package/src/outbox-plan.mjs +218 -0
  44. package/src/outbox.mjs +29 -9
  45. package/src/output-cap.mjs +157 -0
  46. package/src/pause-windows.mjs +81 -2
  47. package/src/pi-model-loader.mjs +77 -0
  48. package/src/podman-stack.mjs +16 -3
  49. package/src/portfolio-snapshot.mjs +304 -0
  50. package/src/prepare-local.mjs +247 -12
  51. package/src/prepare.mjs +35 -3
  52. package/src/priorities.mjs +569 -0
  53. package/src/processor.mjs +599 -170
  54. package/src/project-id.mjs +17 -0
  55. package/src/projects.mjs +238 -0
  56. package/src/provider-steering.mjs +179 -65
  57. package/src/queue.mjs +111 -6
  58. package/src/reserved-env.mjs +30 -0
  59. package/src/run-container.mjs +59 -5
  60. package/src/run-history.mjs +379 -24
  61. package/src/run-mirror.mjs +30 -0
  62. package/src/runtime-settings.mjs +104 -9
  63. package/src/schedules.mjs +33 -1
  64. package/src/scoped-limits.mjs +447 -27
  65. package/src/service.mjs +15 -4
  66. package/src/session-store.mjs +131 -6
  67. package/src/start.mjs +528 -40
  68. package/src/triggers-file.mjs +65 -4
  69. package/src/triggers.mjs +135 -7
  70. package/src/up.mjs +308 -34
  71. package/src/valkey-endpoint.mjs +3 -2
package/src/env-file.mjs CHANGED
@@ -17,7 +17,7 @@
17
17
  * Deliberately dependency-free (node:fs only, and only in the thin wrapper): it must stay importable
18
18
  * from any future setup command without dragging worker config or queue deps along.
19
19
  */
20
- import { chmodSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
20
+ import { chmodSync, chownSync, closeSync, constants as fsConstants, fchmodSync, fchownSync, fstatSync, fsyncSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
21
21
 
22
22
  /**
23
23
  * Pure transform over .env TEXT: set `key` to `value` only where nothing is set yet.
@@ -104,6 +104,15 @@ function replacementLines(key, value, bare, wasComment, opts) {
104
104
  * character, a `"`, a `%`, a `!`, a `^`, a character outside ASCII, and an `=` at the start of the value. Each
105
105
  * row there says whether it is cmd's documented behaviour or a cautious refusal. `'` is ordinary there.
106
106
  */
107
+ /**
108
+ * The `code` on every error `renderEnvValue` throws (issue #522): the VALUE cannot be written, so a caller may add advice
109
+ * about the value (`up` says where its path came from). Every other refusal of the writer is about the FILE and carries
110
+ * its own fix, and `up` once gave those this one's advice, telling a gid mismatch to move the deployment somewhere
111
+ * "without that character in its path".
112
+ */
113
+ export const ENV_VALUE_UNWRITABLE = "ENV_VALUE_UNWRITABLE";
114
+ const unwritableValue = (message) => Object.assign(new Error(message), { code: ENV_VALUE_UNWRITABLE });
115
+
107
116
  export function renderEnvValue(value, { platform = process.platform } = {}) {
108
117
  const v = String(value);
109
118
  // The Windows loader keeps surrounding quotes as part of the value ("Values MUST be UNQUOTED", its own
@@ -111,16 +120,16 @@ export function renderEnvValue(value, { platform = process.platform } = {}) {
111
120
  // refused rather than dressed in quotes that become part of a path.
112
121
  if (platform === "win32") {
113
122
  const bad = cmdValueRefusal(v);
114
- if (bad !== null) throw new Error(`cannot write this value into a .env on Windows: it contains ${bad}, and the .cmd wrapper (deploy/worker-env-wrapper.cmd) cannot be shown to read that back as written. Choose a value without it, or give the service this key through its own environment (pi-dispatch service install --env-setup)`);
123
+ if (bad !== null) throw unwritableValue(`cannot write this value into a .env on Windows: it contains ${bad}, and the .cmd wrapper (deploy/worker-env-wrapper.cmd) cannot be shown to read that back as written. Choose a value without it, or give the service this key through its own environment (pi-dispatch service install --env-setup)`);
115
124
  return v;
116
125
  }
117
126
  if (UNQUOTED_PLAIN.test(v)) return v;
118
- if (v.includes("'") || /[\n\r]/.test(v)) throw new Error(`cannot write this value into a .env safely: ${v.includes("'") ? "it contains a single quote" : "it contains a newline"}`);
127
+ if (v.includes("'") || /[\n\r]/.test(v)) throw unwritableValue(`cannot write this value into a .env safely: ${v.includes("'") ? "it contains a single quote" : "it contains a newline"}`);
119
128
  // Gate round 2 of PR #478: a control or invisible character is read alike by every loader inside single quotes, but
120
129
  // the reader never vouches for it (`QUOTED_CONTROL`: printing it rewrites a terminal), so writing it quoted gave doctor
121
130
  // a line it calls unread. Refused instead, naming the character, never the value.
122
131
  const hidden = invisibleCharacter(v);
123
- if (hidden !== null) throw new Error(`cannot write this value into a .env safely: it contains ${hidden}, which doctor would not show back. Remove it`);
132
+ if (hidden !== null) throw unwritableValue(`cannot write this value into a .env safely: it contains ${hidden}, which doctor would not show back. Remove it`);
124
133
  return `'${v}'`;
125
134
  }
126
135
 
@@ -869,6 +878,104 @@ export function envFileEditCheck(content, path, key, value, opts = {}) {
869
878
  return planEnvEdit(content, path, key, value, opts).error ?? null;
870
879
  }
871
880
 
881
+ /**
882
+ * The group-mismatch refusal (issue #522), its own sentence with its own fix. The group is named where `/etc/group`
883
+ * names it, because "gid 0" sends an operator to look it up and `wheel` does not. It is reached only where the writer
884
+ * could not keep the group (`updateEnvFile` gives the new file the old one wherever this account is in it), and only
885
+ * for a file this account OWNS (the owner refusal comes first), so "run it as another account" is no fix: any other
886
+ * account, a member of that group or root, gets the owner refusal instead. The two fixes are this account joining that
887
+ * group (a new login picks it up), or a group this account is in: `own`, this process's primary group, which an
888
+ * owner can always give its file, and which every later rewrite then keeps. The second is the operator's decision where
889
+ * a service reads the file through its present group, so the sentence says so rather than issuing the chgrp.
890
+ */
891
+ export function groupRefusal(target, group, made, own, groupName = groupNameOf) {
892
+ const named = (id) => {
893
+ const name = groupName(id);
894
+ return name ? `${name} (gid ${id})` : `gid ${id}`;
895
+ };
896
+ const fix = own === null ? "" : `; or, if nothing reads this file through its present group, run \`chgrp ${groupName(own) ?? own} ${quotedForShell(target)}\` and run this again`;
897
+ return `refusing to edit ${target}: its group is ${named(group)}, which this account could not give the new file, and a new file written beside it gets ${named(made)}, so rewriting it (a new file renamed over it) would change its group, which is how a .env the service reads through its group stops being readable. To fix it, add this account to ${groupName(group) ?? `gid ${group}`} and log in again${fix}; otherwise edit the file by hand. Nothing was written`;
898
+ }
899
+
900
+ /** A group's name from `/etc/group`, or `null` (no such line, no such file, or not a POSIX host). */
901
+ export function groupNameOf(id, read = () => readFileSync("/etc/group", "utf8")) {
902
+ try {
903
+ for (const line of String(read()).split("\n")) {
904
+ if (line.startsWith("#")) continue;
905
+ const [name, , gid] = line.split(":");
906
+ // A digits-only field, so an empty one (which `Number` reads as 0) never names gid 0.
907
+ if (name && /^\d+$/.test(gid ?? "") && Number(gid) === id) return name;
908
+ }
909
+ } catch {
910
+ // No group database to read: the number alone is said.
911
+ }
912
+ return null;
913
+ }
914
+
915
+ /** `p` as one shell word: bare when it is plainly safe, single-quoted otherwise. */
916
+ function quotedForShell(p) {
917
+ return /^[A-Za-z0-9_./@%+=:,-]+$/.test(p) ? p : `'${String(p).replace(/'/g, "'\\''")}'`;
918
+ }
919
+
920
+ /**
921
+ * Every call `updateEnvFile` makes, for a production caller's fs seam to spread (issue #522's review). The descriptor
922
+ * calls are optional on the seam, because a test fake has none, so a seam that drops one does not fail: the writer
923
+ * quietly goes back to chowning and chmodding the tmp BY PATH, which a swapped tmp redirects. One list, spread by
924
+ * `up`, `service install` and `setup github`, and pinned by a test that reads all four objects.
925
+ */
926
+ export const ENV_WRITER_FS = Object.freeze({ readFileSync, writeFileSync, renameSync, statSync, chmodSync, chownSync, realpathSync, unlinkSync, openSync, fstatSync, fchownSync, fchmodSync, fsyncSync, closeSync });
927
+
928
+ /** `open(2)` flags for the tmp: created here or refused, never through a link (`O_NOFOLLOW` is POSIX-only; 0 elsewhere). */
929
+ const EXCLUSIVE_CREATE = fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0);
930
+
931
+ /**
932
+ * The tmp, created exclusively at 0600 with `content`, and the four things the writer does to it. Through a DESCRIPTOR
933
+ * where the seam has one (every production seam), so a path swapped after the create is never what is chowned or
934
+ * chmodded; a seam without `openSync` (a test fake) gets the same calls by path, still with the exclusive create.
935
+ */
936
+ function createTmp(fs, tmp, content) {
937
+ if (typeof fs.openSync === "function") {
938
+ const fd = fs.openSync(tmp, EXCLUSIVE_CREATE, 0o600);
939
+ try {
940
+ fs.writeFileSync(fd, content);
941
+ } catch (err) {
942
+ fs.closeSync(fd);
943
+ try {
944
+ fs.unlinkSync?.(tmp);
945
+ } catch {
946
+ // The write's error is the one to report.
947
+ }
948
+ throw err;
949
+ }
950
+ let open = true;
951
+ const shut = () => {
952
+ if (open) fs.closeSync(fd);
953
+ open = false;
954
+ };
955
+ return {
956
+ gid: () => fs.fstatSync(fd).gid,
957
+ chgrp: (g) => fs.fchownSync(fd, -1, g),
958
+ chmod: (m) => fs.fchmodSync(fd, m),
959
+ close: () => {
960
+ try {
961
+ fs.fsyncSync?.(fd);
962
+ } finally {
963
+ shut();
964
+ }
965
+ },
966
+ abandon: shut,
967
+ };
968
+ }
969
+ fs.writeFileSync(tmp, content, { mode: 0o600, flag: "wx" });
970
+ return {
971
+ gid: () => fs.statSync(tmp).gid,
972
+ chgrp: (g) => fs.chownSync(tmp, -1, g),
973
+ chmod: (m) => fs.chmodSync(tmp, m),
974
+ close: () => {},
975
+ abandon: () => {},
976
+ };
977
+ }
978
+
872
979
  /**
873
980
  * Read → transform → write back ATOMICALLY (tmp + rename, the same shape as the admin's
874
981
  * writeTriggers), so a watcher or a concurrent reader never sees a half-written .env. When the
@@ -895,7 +1002,7 @@ export function updateEnvFile(path, key, value, deps = {}) {
895
1002
  // and the cmd wrapper kept the quotes, making the path the worker loads wrong behind a ✓ on the key
896
1003
  // that decides forge auth. A rendering rule that every writer of this file must remember is a rule one
897
1004
  // of them will forget.
898
- const { fs = { readFileSync, writeFileSync, renameSync, statSync, chmodSync, realpathSync }, overwrite = false, platform = process.platform, narrow = false } = deps;
1005
+ const { fs = ENV_WRITER_FS, overwrite = false, platform = process.platform, narrow = false } = deps;
899
1006
  // Every refusal is `planEnvEdit`'s (bytes first, never clobber, read back), made before anything is written.
900
1007
  const plan = planEnvEdit(fs.readFileSync(path), path, key, value, { overwrite, platform, verify: deps.verify });
901
1008
  if (plan.error) throw new Error(plan.error);
@@ -911,42 +1018,104 @@ export function updateEnvFile(path, key, value, deps = {}) {
911
1018
  } catch {
912
1019
  // Not resolvable (a dangling link, a fs without the call): edit the path we were given.
913
1020
  }
914
- // OWNERSHIP, before anything is written. `renameSync` makes a new inode owned by whoever runs this, so
1021
+ // OWNERSHIP, before the file is replaced. `renameSync` makes a new inode owned by whoever runs this, so
915
1022
  // a root- or `pi`-owned `.env` at 0640 that the service reads through its group comes back owned by the
916
1023
  // operator: the service account loses read access, and `deploy/worker.service` uses a bare
917
1024
  // `EnvironmentFile=` (fatal, not `-`), so the unit stops starting. Widening the mode to compensate
918
1025
  // would publish a file holding WEBHOOK_SECRET. Refusing is the only honest third option, and the caller
919
- // turns it into a line rather than a stack trace.
1026
+ // turns it into a line rather than a stack trace. Each refusal carries its own fix (issue #522).
920
1027
  const uid = typeof process.getuid === "function" ? process.getuid() : null;
921
1028
  const gid = typeof process.getgid === "function" ? process.getgid() : null;
1029
+ let group = null;
922
1030
  if (uid !== null) {
923
1031
  try {
924
- const { uid: owner, gid: group } = fs.statSync(target);
925
- // GID as well as UID, because the layout this protects is a `.env` at 0640 read by the service
926
- // THROUGH ITS GROUP. With `bob:pi 0640` and the operator in group `pi` the uid matches, the
927
- // rename still makes a new inode with the writer's primary gid, and the `pi` service loses read
928
- // access exactly as it would have on a uid mismatch.
929
- if (typeof owner === "number" && owner !== uid) throw new Error(`refusing to edit ${target}: it is owned by uid ${owner} and this process is uid ${uid}, and rewriting it would hand it to the wrong account`);
930
- if (typeof group === "number" && gid !== null && group !== gid) throw new Error(`refusing to edit ${target}: its group is gid ${group} and this process is gid ${gid}, and rewriting it would hand it to the wrong group, which is how a 0640 .env stops being readable by the service`);
1032
+ const { uid: owner, gid: had } = fs.statSync(target);
1033
+ if (typeof owner === "number" && owner !== uid) throw new Error(`refusing to edit ${target}: it is owned by uid ${owner} and this process is uid ${uid}, and rewriting it (a new file renamed over it) would give it to this account, which is how a .env the service reads stops being readable. To fix it, run this as the account that owns it, or edit the file by hand. Nothing was written`);
1034
+ if (typeof had === "number") group = had;
931
1035
  } catch (err) {
932
1036
  if (err instanceof Error && err.message.startsWith("refusing to edit")) throw err;
933
1037
  // Cannot stat: fall through to the write, which will fail on its own terms if it must.
934
1038
  }
935
1039
  }
936
1040
  const tmp = `${target}.tmp`;
937
- fs.writeFileSync(tmp, next, { mode: 0o600 });
1041
+ // CREATED HERE, exclusively, or not at all (issue #522's review). The folder may be one another account can write (a
1042
+ // setgid 2770 folder shared with the service's group), and a `.env.tmp` planted there as a symlink was followed by
1043
+ // the write, by the chown that keeps the group, and by the chmod: content at the link's target, and that file's
1044
+ // group and mode changed. `O_EXCL` refuses any existing path, a symlink included (POSIX: not followed), and
1045
+ // `O_NOFOLLOW` says so twice where the platform has it; everything after the create goes through the descriptor, so
1046
+ // a tmp swapped after the create is not what is chowned or chmodded. An existing tmp is refused and left alone: it is
1047
+ // not this writer's to remove.
1048
+ let handle;
938
1049
  try {
939
- // The operator's mode, whatever it is, not just 0600. `.env` holds WEBHOOK_SECRET and provider
940
- // keys, and a rename from a fresh tmp lands at the process umask: 0640 and 0400 both came back
941
- // 0644, world-readable, on the one file this project says must never reach a scrollback.
942
- // `narrow` keeps only the owner's bits of it.
943
- const mode = fs.statSync(target).mode & 0o7777;
944
- fs.chmodSync(tmp, narrow ? mode & 0o7700 : mode);
945
- } catch {
946
- // The file vanished between read and write, or the fs cannot stat: the tmp keeps the 0600 it was
947
- // created with rather than failing an edit that is otherwise sound.
1050
+ handle = createTmp(fs, tmp, next);
1051
+ } catch (err) {
1052
+ if (err?.code === "EEXIST" || err?.code === "ELOOP") throw new Error(`refusing to edit ${target}: ${tmp} already exists (left by an edit that was interrupted, or put there by another account that can write this folder), and this writer only writes through a file it has just created. Remove it, then run this again. Nothing was written`);
1053
+ throw err;
1054
+ }
1055
+ // GROUP, as well as owner, because the layout this protects is a `.env` at 0640 read by the service THROUGH ITS GROUP:
1056
+ // with `bob:pi 0640` and the operator in group `pi`, the uid matches and the rename can still change the group.
1057
+ // MEASURED on the tmp, not predicted from this process's gid (issue #522). Which group a new file gets is the
1058
+ // folder's business: on macOS and the BSDs it is always the folder's group, and on Linux it is the folder's group
1059
+ // where the folder is setgid (or mounted `grpid`), else this process's. The prediction refused every edit to a
1060
+ // `.env` that `init` had just made in a folder of group `wheel` (gid 0, as `/private/tmp` is): the file took the
1061
+ // folder's group, this process's was `staff`, and the rewrite would have kept `wheel` all along. The tmp holds the
1062
+ // new content at 0600 and is removed before the refusal, so nothing the operator reads has changed.
1063
+ // EVERYTHING after the create removes the tmp on any failure (issue #522's review, round 2): a flush or a rename that
1064
+ // throws (EPERM from a Windows scanner holding the file, a full disk at fsync) left this writer's OWN tmp behind, and
1065
+ // every later edit then refused it as one that "already exists". Removing it here is safe where removing one found at
1066
+ // the create is not: this one was created by this call, exclusively. The original error is rethrown; where the tmp
1067
+ // cannot be removed, its message says where the new content was left instead of claiming nothing was written.
1068
+ try {
1069
+ if (group !== null) {
1070
+ let made = null;
1071
+ try {
1072
+ made = handle.gid();
1073
+ } catch {
1074
+ // Cannot stat what was just written: predict as a folder without setgid would make it.
1075
+ }
1076
+ if (typeof made !== "number") made = gid;
1077
+ // KEPT where this account may keep it: an owner may give a file any group it is in, so the tmp takes the file's
1078
+ // group and the rewrite changes nothing (`bob:pi` with the operator in `pi`, or a `staff` file in a `wheel`
1079
+ // folder). Refused only where that fails, which is a group this account is not in.
1080
+ if (made !== null && made !== group) {
1081
+ try {
1082
+ handle.chgrp(group);
1083
+ made = handle.gid();
1084
+ } catch {
1085
+ // EPERM, a group this account is not in: the refusal below says so.
1086
+ }
1087
+ }
1088
+ if (made !== null && made !== group) {
1089
+ throw new Error(groupRefusal(target, group, made, gid, deps.groupName ?? groupNameOf));
1090
+ }
1091
+ }
1092
+ try {
1093
+ // The operator's mode, whatever it is, not just 0600. `.env` holds WEBHOOK_SECRET and provider
1094
+ // keys, and a rename from a fresh tmp lands at the process umask: 0640 and 0400 both came back
1095
+ // 0644, world-readable, on the one file this project says must never reach a scrollback.
1096
+ // `narrow` keeps only the owner's bits of it.
1097
+ const mode = fs.statSync(target).mode & 0o7777;
1098
+ handle.chmod(narrow ? mode & 0o7700 : mode);
1099
+ } catch {
1100
+ // The file vanished between read and write, or the fs cannot stat: the tmp keeps the 0600 it was
1101
+ // created with rather than failing an edit that is otherwise sound.
1102
+ }
1103
+ handle.close();
1104
+ fs.renameSync(tmp, target);
1105
+ } catch (err) {
1106
+ handle.abandon();
1107
+ let removed = typeof fs.unlinkSync === "function";
1108
+ try {
1109
+ if (removed) fs.unlinkSync(tmp);
1110
+ } catch {
1111
+ removed = false;
1112
+ }
1113
+ if (!removed && err instanceof Error) {
1114
+ const left = `${target} itself was not changed, but the new content was left in ${tmp} (mode 0600), which could not be removed: remove it`;
1115
+ err.message = err.message.endsWith(". Nothing was written") ? `${err.message.slice(0, -"Nothing was written".length)}${left}` : `${err.message}. ${left}`;
1116
+ }
1117
+ throw err;
948
1118
  }
949
- fs.renameSync(tmp, target);
950
1119
  return { changed: true, ...(narrow ? { narrowed: true } : {}) };
951
1120
  }
952
1121