querysub 0.556.0 → 0.558.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.
@@ -0,0 +1,369 @@
1
+ import os from "os";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { spawn, ChildProcess } from "child_process";
5
+ import { lazy } from "socket-function/src/caching";
6
+ import { measureWrap } from "socket-function/src/profiling/measure";
7
+ import { delay, runInSerial } from "socket-function/src/batching";
8
+ import { red, green } from "socket-function/src/formatting/logColors";
9
+ import { runPromise } from "../functional/runCommand";
10
+ import { forceRemoveNode } from "../-f-node-discovery/NodeDiscovery";
11
+ import { fsExistsAsync } from "../fs";
12
+ import { PromiseObj } from "../promise";
13
+ import { SERVICE_FOLDER, SERVICE_NODE_FILE_NAME } from "./machineSchema";
14
+ import { ProcessRecord, createLaunchId, getLogPipeScript, getLogTailScript, getProcessLogPath, getPipeScriptPath, getTailScriptPath, writeProcessRecord } from "./processLogs";
15
+
16
+ // Running, inspecting and killing the tmux screens services run in. This layer only knows "here is a configuration, run it" - which version should be running when is the deploy logic's problem.
17
+
18
+ const SCREEN_SUFFIX = "-dply";
19
+ export function getScreenName(config: { serviceKey: string; index: number }): string {
20
+ return `${config.serviceKey}-${config.index}${SCREEN_SUFFIX}`.replace(/[^a-zA-Z0-9\-_]/g, "_");
21
+ }
22
+ // The new version's screen during a release, in the SAME folder as the canonical screen: created (just echoing when it will start) shortly before releaseTime, started at releaseTime, and renamed to the canonical screen name once the old screen is killed at releaseTime + overlapTime.
23
+ export function getFutureScreenName(canonicalScreenName: string): string {
24
+ return canonicalScreenName.slice(0, -SCREEN_SUFFIX.length) + "-future" + SCREEN_SUFFIX;
25
+ }
26
+
27
+
28
+ async function removeOldNodeId(screenName: string) {
29
+ let nodeIdFile = os.homedir() + "/" + SERVICE_FOLDER + screenName + "/" + SERVICE_NODE_FILE_NAME;
30
+ if (await fsExistsAsync(nodeIdFile)) {
31
+ let nodeId = await fs.promises.readFile(nodeIdFile, "utf8");
32
+ console.log(green(`Removing node if for dead service on ${nodeIdFile}, node id ${nodeId}`));
33
+ await fs.promises.unlink(nodeIdFile);
34
+ await forceRemoveNode(nodeId);
35
+ }
36
+ }
37
+
38
+ export const getTmuxPrefix = lazy(() => {
39
+ if (os.platform() === "win32") {
40
+ return "C:/cygwin64/bin/";
41
+ // C:/cygwin64/bin/tmux new -s server1 -d
42
+ }
43
+ return "";
44
+ });
45
+ function textTableLineToObj(text: string): Record<string, string>[] {
46
+ /*
47
+ PID PPID PGID WINPID TTY UID STIME COMMAND
48
+ 1312 1284 1312 56996 pty2 197609 09:24:47 /usr/bin/bash
49
+ 1313 1284 1313 56997 pty3 197609 09:24:48 /usr/bin/vim
50
+ =>
51
+ [{ PID: "1312", PPID: "1284", ...}, { PID: "1313", PPID: "1284", ...}]
52
+ */
53
+ let lines = text.split("\n").filter(line => line.trim().length > 0);
54
+ if (lines.length < 2) {
55
+ return [];
56
+ }
57
+
58
+ let headerLine = lines[0];
59
+ let dataLines = lines.slice(1);
60
+
61
+ // Parse column positions from header
62
+ let headerWords = headerLine.match(/\S+/g) || [];
63
+ if (headerWords.length === 0) return [];
64
+
65
+ // Find column boundaries: start at 0, then at end of each header word (except last), then start of last header, then end of line
66
+ let boundaries: number[] = [0];
67
+ let searchPos = 0;
68
+ for (let i = 0; i < headerWords.length; i++) {
69
+ let word = headerWords[i];
70
+ searchPos = headerLine.indexOf(word, searchPos) + word.length;
71
+ boundaries.push(searchPos);
72
+ }
73
+ boundaries[boundaries.length - 1] = Number.MAX_SAFE_INTEGER;
74
+
75
+ // Create column definitions using boundaries
76
+ let columns: { name: string; start: number; end: number }[] = [];
77
+ for (let i = 0; i < headerWords.length; i++) {
78
+ columns.push({
79
+ name: headerWords[i],
80
+ start: boundaries[i],
81
+ end: boundaries[i + 1]
82
+ });
83
+ }
84
+
85
+ // Extract values from all data lines
86
+ let results: Record<string, string>[] = [];
87
+ for (let dataLine of dataLines) {
88
+ let result: Record<string, string> = {};
89
+ for (let column of columns) {
90
+ let value = dataLine.substring(column.start, column.end).trim();
91
+ result[column.name] = value;
92
+ }
93
+ results.push(result);
94
+ }
95
+
96
+ return results;
97
+ }
98
+ const getLinuxChildPids = measureWrap(async function getLinuxChildPids(pid: string): Promise<{ PID: string; PPID: string; CMD: string }[]> {
99
+ let prefix = getTmuxPrefix();
100
+ if (os.platform() === "win32") {
101
+ let table = await runPromise(`${prefix}ps`, { quiet: true });
102
+ let obj = textTableLineToObj(table) as { PID: string; PPID: string; CMD: string }[];
103
+ return obj.filter(x => x.PPID === pid);
104
+ } else {
105
+ let table = await runPromise(`ps -eo pid,ppid,cmd`, { quiet: true });
106
+ let obj = textTableLineToObj(table) as { PPID: string; PID: string; CMD: string }[];
107
+ return obj.filter(x => x.PPID === pid);
108
+ }
109
+ });
110
+ // One process-table read for the whole machine — a pid is "running something" when any process has it as a parent. Used by getScreenState so a resync doesn't spawn one ps per screen (which made the release boundary ticks wait on every idle screen first).
111
+ const getAllParentPids = measureWrap(async function getAllParentPids(): Promise<Set<string>> {
112
+ let prefix = getTmuxPrefix();
113
+ let table = os.platform() === "win32"
114
+ ? await runPromise(`${prefix}ps`, { quiet: true })
115
+ : await runPromise(`ps -eo pid,ppid,cmd`, { quiet: true });
116
+ let obj = textTableLineToObj(table) as { PID: string; PPID: string }[];
117
+ return new Set(obj.map(x => x.PPID));
118
+ });
119
+ /** The start time the OS reports for a pid, as an epoch time in milliseconds like every other time we store. Asked of the machine rather than remembered, so it stays true across our restarts and catches a reused pid. Returns 0 when the process is gone. */
120
+ export const getProcessStartTime = measureWrap(async function getProcessStartTime(pid: string): Promise<number> {
121
+ // ps only resolves to the second, so this is second-accurate - which is plenty to tell two processes on the same pid apart
122
+ let started = await runPromise(`date -d "$(ps -o lstart= -p ${pid})" +%s`, { quiet: true });
123
+ return (parseInt(started.trim()) || 0) * 1000;
124
+ });
125
+
126
+ export const isScreenRunningProcess = measureWrap(async function isScreenRunningProcess(pid: string): Promise<boolean> {
127
+ try {
128
+ let bashChildPids = await getLinuxChildPids(pid);
129
+ for (let childPid of bashChildPids) {
130
+ console.log(`Screen pid ${pid} is running ${childPid.CMD}`);
131
+ return true;
132
+ }
133
+ console.log(`Screen pid ${pid} is not running anything.`);
134
+ return false;
135
+ } catch (e: any) {
136
+ console.warn(`Error checking if screen is running for ${pid}: ${e.stack}`);
137
+ return false;
138
+ }
139
+ });
140
+ export const getScreenState = measureWrap(async function getScreenState(populateIsProcessRunning: boolean = true): Promise<{
141
+ screenName: string;
142
+ isProcessRunning: boolean;
143
+ pid: string;
144
+ }[]> {
145
+ const prefix = getTmuxPrefix();
146
+ const delimit = "::::";
147
+ // Use list-sessions instead of list-panes -a to avoid zombie sessions
148
+ // 2>/dev/null suppresses "no server running" errors, -r prevents xargs from running if input is empty
149
+ let screenList = (await runPromise(`${prefix}tmux list-sessions -F "#{session_name}" 2>/dev/null | xargs -r -I {} tmux list-panes -t {} -F "{}${delimit}#{pane_pid}"`))
150
+ .split("\n")
151
+ .filter(x => x.includes(delimit))
152
+ .map(x => ({
153
+ screenName: x.split(delimit)[0].trim(),
154
+ pid: x.split(delimit)[1].trim(),
155
+ isProcessRunning: false,
156
+ }))
157
+ .filter(x => x.screenName.endsWith(SCREEN_SUFFIX))
158
+ ;
159
+
160
+ if (populateIsProcessRunning) {
161
+ let parentPids = await getAllParentPids();
162
+ for (let x of screenList) {
163
+ x.isProcessRunning = parentPids.has(x.pid);
164
+ }
165
+ }
166
+
167
+ return screenList;
168
+ });
169
+
170
+ export const runScreenCommand = measureWrap(async function runScreenCommand(config: {
171
+ screenName: string;
172
+ command: string;
173
+ // Defaults to the folder derived from screenName; a future screen passes its canonical screen's folder
174
+ folder?: string;
175
+ // Identifies the process this launch creates, so its log and its configuration are stored against it
176
+ record: Omit<ProcessRecord, "launchId" | "folder" | "screenName" | "startTime">;
177
+ }): Promise<string> {
178
+ let prefix = getTmuxPrefix();
179
+ let screenName = config.screenName;
180
+
181
+ try {
182
+ // Throw if it already exists
183
+ await runPromise(`${prefix}tmux new -s ${screenName} -d`);
184
+ } catch { }
185
+ await runPromise(`${prefix}tmux send-keys -t ${screenName} 'echo "Updating running command at ${new Date().toISOString()}"' Enter`);
186
+ await runPromise(`${prefix}tmux send-keys -t ${screenName} 'C-c' Enter`);
187
+ await delay(1000);
188
+
189
+
190
+ let screens = await getScreenState();
191
+ let screen = screens.find(x => x.screenName === screenName);
192
+ let pid = screen?.pid;
193
+ if (pid && await isScreenRunningProcess(pid)) {
194
+ // It doesn't want to die. Wait longer, but it it just won't die, kill the screen
195
+ console.warn(`Screen ${screenName} is not dying, giving it another 30 seconds`);
196
+ for (let i = 0; i < 6; i++) {
197
+ await delay(5);
198
+ if (!await isScreenRunningProcess(pid)) {
199
+ break;
200
+ }
201
+ }
202
+ if (pid && await isScreenRunningProcess(pid)) {
203
+ console.warn(`Screen ${screenName} is still running, killing it forcefully`);
204
+ await killScreen({ screenName });
205
+ if (pid && await isScreenRunningProcess(pid)) {
206
+ console.error(`I don't know what happened. The screen won't die. We can't do much else, I guess we'll just ignore it...`);
207
+ } else {
208
+ // Nested, to create the screen again.
209
+ return await runScreenCommand({
210
+ screenName,
211
+ command: config.command,
212
+ folder: config.folder,
213
+ record: config.record,
214
+ });
215
+ }
216
+ }
217
+ }
218
+ await removeOldNodeId(screenName);
219
+ let folder = config.folder || os.homedir() + "/" + SERVICE_FOLDER + screenName + "/";
220
+
221
+ // The pane we are about to run the command in identifies the process: its pid, and the start time the OS reports for that pid. Both can be re-checked against the machine later, so nothing downstream has to trust our bookkeeping about what is still running.
222
+ let panePid = (await getScreenState(false)).find(x => x.screenName === screenName)?.pid;
223
+ if (!panePid) {
224
+ throw new Error(`Screen ${screenName} does not exist after creating it, so there is no process to run the command in`);
225
+ }
226
+ let startTime = await getProcessStartTime(panePid);
227
+ let launchId = createLaunchId(panePid, startTime);
228
+ await writeProcessRecord({
229
+ ...config.record,
230
+ launchId,
231
+ folder,
232
+ screenName,
233
+ pid: parseInt(panePid) || undefined,
234
+ startTime,
235
+ });
236
+ await runPromise(`${prefix}tmux send-keys -t ${screenName} 'cd ${folder}git' Enter`);
237
+ let command = `#!/bin/bash
238
+ ${config.command}
239
+ `;
240
+ await fs.promises.writeFile(folder + "command.sh", command);
241
+ await runPromise(`${prefix}tmux send-keys -t ${screenName} 'bash ../command.sh' Enter`);
242
+
243
+ await setupPipePane({ screenName, folder, launchId });
244
+ return launchId;
245
+ });
246
+
247
+ // Points the screen's pipe-pane at this launch's own log file.
248
+ async function setupPipePane(config: { screenName: string; folder: string; launchId: string }) {
249
+ let prefix = getTmuxPrefix();
250
+ let logPath = getProcessLogPath(config.folder, config.launchId);
251
+ let pipeScript = getPipeScriptPath(config.folder, config.launchId);
252
+ await fs.promises.mkdir(path.dirname(logPath), { recursive: true });
253
+ await fs.promises.writeFile(pipeScript, getLogPipeScript(logPath));
254
+ await runPromise(`chmod +x ${pipeScript}`);
255
+ await runPromise(`${prefix}tmux pipe-pane -t ${config.screenName} 'bash ${pipeScript}'`);
256
+ }
257
+
258
+ export const killScreen = measureWrap(async function killScreen(config: {
259
+ screenName: string;
260
+ // During a takeover the folder's nodeId file already belongs to the NEW process, so the old screen's kill must not remove it
261
+ skipNodeIdRemoval?: boolean;
262
+ }) {
263
+ console.log(red(`Killing screen ${config.screenName}`));
264
+ let prefix = getTmuxPrefix();
265
+ // Try ctrl+c a few times first
266
+ let pid = (await getScreenState(false)).find(x => x.screenName === config.screenName)?.pid;
267
+ for (let i = 0; i < 5; i++) {
268
+ if (!pid || !await isScreenRunningProcess(pid)) {
269
+ break;
270
+ }
271
+ await runPromise(`${prefix}tmux send-keys -t ${config.screenName} 'C-c' Enter`);
272
+ await delay(5000);
273
+ }
274
+ await runPromise(`${prefix}tmux kill-session -t ${config.screenName}`);
275
+ if (!config.skipNodeIdRemoval) {
276
+ await removeOldNodeId(config.screenName);
277
+ }
278
+ });
279
+
280
+ /** Streams one process's log: everything already in it, then everything appended after. One process, one file - nothing here has to reason about which process a byte came from. */
281
+ export async function streamProcessOutput(config: {
282
+ folder: string;
283
+ launchId: string;
284
+ onData: (data: string) => Promise<void>;
285
+ }) {
286
+ let logPath = getProcessLogPath(config.folder, config.launchId);
287
+ let serialOnData = runInSerial(config.onData);
288
+ let stopped = false;
289
+ let childProcess: ChildProcess | undefined;
290
+
291
+ let pendingDataCalls = 0;
292
+ const MAX_PENDING_CALLS = 100;
293
+
294
+ async function stop() {
295
+ if (stopped) return;
296
+ stopped = true;
297
+ if (childProcess) {
298
+ childProcess.kill();
299
+ }
300
+ }
301
+
302
+ const onDataWrapped = async (data: string) => {
303
+ pendingDataCalls++;
304
+ if (pendingDataCalls > MAX_PENDING_CALLS) {
305
+ console.error(`Too many queued onData calls for ${config.launchId}, stopping stream.`);
306
+ await stop();
307
+ return;
308
+ }
309
+ try {
310
+ await serialOnData(data);
311
+ } catch (e: any) {
312
+ console.log(`Callback for stream output ${config.launchId} failed. It probably just disconnected, almost certainly not an error: ${e.message}`);
313
+ await stop();
314
+ } finally {
315
+ pendingDataCalls--;
316
+ }
317
+ };
318
+
319
+ try {
320
+ let tailScript = getTailScriptPath(config.folder, config.launchId);
321
+ await fs.promises.writeFile(tailScript, getLogTailScript(logPath));
322
+ await runPromise(`chmod +x ${tailScript}`);
323
+
324
+ // Read what is already there ourselves and deliver it as one call - letting the tail script cat it
325
+ // makes runInSerial dribble it out one round trip at a time.
326
+ let initialContent = "";
327
+ try {
328
+ initialContent = await fs.promises.readFile(logPath, "utf8");
329
+ } catch {
330
+ // The process may not have written anything yet; the tail script picks it up when it does.
331
+ }
332
+
333
+ childProcess = spawn("bash", [tailScript, String(Buffer.byteLength(initialContent, "utf8"))], {
334
+ stdio: "pipe",
335
+ });
336
+
337
+ let started = new PromiseObj<void>();
338
+
339
+ childProcess.stdout?.on("data", (data) => {
340
+ if (stopped) return;
341
+ started.resolve();
342
+ void onDataWrapped(data.toString());
343
+ });
344
+ // Give it some time to error out, otherwise, just start
345
+ setTimeout(() => started.resolve(), 200);
346
+
347
+ childProcess.stderr?.on("data", (data) => {
348
+ if (stopped) return;
349
+ void onDataWrapped(red(data.toString()));
350
+ });
351
+
352
+ childProcess.on("error", async (err) => {
353
+ if (stopped) return;
354
+ started.reject(err);
355
+ });
356
+
357
+ if (initialContent) {
358
+ // Queued synchronously here, before any stdout "data" event can fire, so it always lands first
359
+ started.resolve();
360
+ void onDataWrapped(initialContent);
361
+ }
362
+
363
+ await started.promise;
364
+ } catch (e) {
365
+ void stop();
366
+ throw e;
367
+ }
368
+ }
369
+
@@ -1,4 +1,4 @@
1
- import { getBackblazePath } from "../../appSecrets";
1
+ import { getBackblazePath } from "../misc/appPaths";
2
2
  import { getGitURLLive, getGitRefLive } from "../4-deploy/git";
3
3
  import { Querysub } from "../4-querysub/Querysub";
4
4
  import { runPromise } from "../functional/runCommand";
@@ -3,7 +3,6 @@ import { lazy } from "socket-function/src/caching";
3
3
  import { formatNumber } from "socket-function/src/formatting/format";
4
4
  import { blue } from "socket-function/src/formatting/logColors";
5
5
  import { isNode } from "socket-function/src/misc";
6
- import { registerPeriodic } from "./periodic";
7
6
  import { registerMeasureInfo } from "socket-function/src/profiling/measure";
8
7
  import { logNodeStateStats } from "../-0-hooks/hooks";
9
8
 
@@ -89,4 +88,7 @@ registerMeasureInfo(() => {
89
88
  return `MEM ${formatNumber(getUsedHeapSize())}B+${formatNumber(getBufferUsage())}B/${formatNumber(getHeapSize())}B `;
90
89
  });
91
90
 
92
- registerPeriodic(logResourcesNow);
91
+ setImmediate(async () => {
92
+ let { registerPeriodic } = await import("./periodic");
93
+ registerPeriodic(logResourcesNow);
94
+ });
@@ -170,6 +170,12 @@ export function createURLSync<T>(urlKey: string, defaultValue: T, config?: URLPa
170
170
  return param;
171
171
  }
172
172
 
173
+ if (!syncSchema) {
174
+ debugger;
175
+ require("debugbreak")(2);
176
+ debugger;
177
+ }
178
+
173
179
  // TODO: Support pathname keys (ex /name-value)
174
180
  const { data } = syncSchema<{
175
181
  params: {
@@ -0,0 +1,56 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import { isNode } from "socket-function/src/misc";
4
+ import { lazy } from "socket-function/src/caching";
5
+ import { parseArgsFactory } from "./rawParams";
6
+
7
+ // Where the machine's own configuration and credentials live on disk. Deliberately dependency-free (beyond rawParams, which has no dependencies of its own): the storage layer needs these paths, and it sits far below the application - routing this through appSecrets.ts made the lowest-level archives code depend on Querysub.
8
+
9
+ // getSecret always imports appSecrets relative to the process cwd, so the cwd is the repo root and this matches what getStorageDir would resolve
10
+ export const STORAGE_DIR = "./database-storage/";
11
+
12
+ // querysubConfig and getDomain are copied verbatim from src/config.ts, so this file has no dependencies on our own application
13
+
14
+ let yargObj = parseArgsFactory()
15
+ .option("domain", { type: "string", desc: `Sets the domain` })
16
+ .argv
17
+ ;
18
+
19
+ type QuerysubConfig = {
20
+ domain?: string;
21
+ emaildomain?: string;
22
+ notifyemails?: string[];
23
+ };
24
+ let querysubConfig = lazy((): QuerysubConfig => {
25
+ if (!isNode()) throw new Error("querysubConfig is only available on the server");
26
+ const path = "./querysub.json";
27
+ if (!fs.existsSync(path)) {
28
+ return {};
29
+ }
30
+ try {
31
+ return JSON.parse(fs.readFileSync(path, "utf8"));
32
+ } catch (e) {
33
+ console.error("Error parsing querysub.json", e);
34
+ return {};
35
+ }
36
+ });
37
+
38
+ export function getDomain() {
39
+ if (!isNode()) {
40
+ return location.hostname.split(".").slice(-2).join(".");
41
+ }
42
+ return yargObj.domain || querysubConfig().domain || "querysub.com";
43
+ }
44
+
45
+ export function getBackblazePath() {
46
+ let testPaths = [
47
+ STORAGE_DIR + "backblaze.json",
48
+ os.homedir() + "/backblaze.json",
49
+ ];
50
+ for (let path of testPaths) {
51
+ if (fs.existsSync(path)) {
52
+ return path;
53
+ }
54
+ }
55
+ return testPaths[0];
56
+ }