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.
- package/appSecrets.ts +1 -52
- package/package.json +1 -1
- package/src/-0-hooks/hooks.ts +2 -2
- package/src/-a-archives/archivesBackBlaze.ts +1 -1
- package/src/0-path-value-core/PathWatcher.ts +6 -0
- package/src/4-querysub/Querysub.ts +0 -1
- package/src/deployManager/components/ProcessesView.tsx +169 -0
- package/src/deployManager/components/RouteConfigView.tsx +28 -2
- package/src/deployManager/components/ServiceDetailPage.tsx +9 -124
- package/src/deployManager/machineApplyMainCode.ts +0 -0
- package/src/deployManager/machineController.ts +40 -37
- package/src/deployManager/processLogs.ts +171 -0
- package/src/deployManager/processManager.ts +369 -0
- package/src/deployManager/setupMachineMain.ts +1 -1
- package/src/diagnostics/trackResources.ts +4 -2
- package/src/library-components/URLParam.ts +6 -0
- package/src/misc/appPaths.ts +56 -0
|
@@ -5,7 +5,8 @@ import { requiresNetworkTrustHook } from "../-d-trust/NetworkTrust2";
|
|
|
5
5
|
import { timeInMinute } from "socket-function/src/misc";
|
|
6
6
|
import { assertIsManagementUser } from "../diagnostics/managementPages";
|
|
7
7
|
import { isNode } from "typesafecss";
|
|
8
|
-
import {
|
|
8
|
+
import { streamProcessOutput } from "./processManager";
|
|
9
|
+
import { ProcessRecord, listProcessRecords } from "./processLogs";
|
|
9
10
|
import { Querysub } from "../4-querysub/Querysub";
|
|
10
11
|
import { getPathStr2 } from "../path";
|
|
11
12
|
import { getGitURLLive, setGitRef } from "../4-deploy/git";
|
|
@@ -72,38 +73,43 @@ export const OnServiceChange = SocketFunction.register(
|
|
|
72
73
|
|
|
73
74
|
class MachineControllerBase {
|
|
74
75
|
// NOTE: We don't need to worry about escaping commands here. YES, the user CAN inject code into the key. But this system is literally for running arbitrary commands, so they could just write a serviceConfig and run anything they want, on all the machines...
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
/** Every process this machine has a record of, running or dead. */
|
|
77
|
+
public async listProcesses(): Promise<ProcessRecord[]> {
|
|
78
|
+
return await listProcessRecords();
|
|
79
|
+
}
|
|
80
|
+
public async streamProcessOutput(config: {
|
|
81
|
+
folder: string;
|
|
82
|
+
launchId: string;
|
|
78
83
|
callbackId: string;
|
|
79
84
|
}): Promise<void> {
|
|
80
85
|
let caller = SocketFunction.getCaller();
|
|
81
|
-
await
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
onData: async (data
|
|
86
|
+
await streamProcessOutput({
|
|
87
|
+
folder: config.folder,
|
|
88
|
+
launchId: config.launchId,
|
|
89
|
+
onData: async (data) => {
|
|
85
90
|
await MachineControllerClient.nodes[caller.nodeId].onScreenOutput({
|
|
86
|
-
|
|
87
|
-
index: config.index,
|
|
91
|
+
launchId: config.launchId,
|
|
88
92
|
data,
|
|
89
|
-
reset: dataConfig?.reset,
|
|
90
93
|
callbackId: config.callbackId,
|
|
91
94
|
});
|
|
92
95
|
},
|
|
93
96
|
});
|
|
94
97
|
}
|
|
95
|
-
// We need to forward
|
|
96
|
-
public async
|
|
98
|
+
// We need to forward these, as clients can't connect to the machines directly (they don't have real HTTP certificates).
|
|
99
|
+
public async listOtherProcesses(config: { nodeId: string }): Promise<ProcessRecord[]> {
|
|
100
|
+
return await MachineController(config.nodeId).listProcesses.promise();
|
|
101
|
+
}
|
|
102
|
+
public async watchOtherProcessOutput(config: {
|
|
97
103
|
nodeId: string;
|
|
98
|
-
|
|
99
|
-
|
|
104
|
+
folder: string;
|
|
105
|
+
launchId: string;
|
|
100
106
|
callbackId: string;
|
|
101
107
|
}) {
|
|
102
108
|
let caller = SocketFunction.getCaller();
|
|
103
109
|
forwardedCallbacks.set(config.callbackId, caller.nodeId);
|
|
104
|
-
await MachineController(config.nodeId).
|
|
105
|
-
|
|
106
|
-
|
|
110
|
+
await MachineController(config.nodeId).streamProcessOutput.promise({
|
|
111
|
+
folder: config.folder,
|
|
112
|
+
launchId: config.launchId,
|
|
107
113
|
callbackId: config.callbackId,
|
|
108
114
|
});
|
|
109
115
|
}
|
|
@@ -137,8 +143,10 @@ export const MachineController = getSyncedController(SocketFunction.register(
|
|
|
137
143
|
"machine-controller-c3157d4a-580c-4e76-9dc9-072dd92e70af",
|
|
138
144
|
() => new MachineControllerBase(),
|
|
139
145
|
() => ({
|
|
140
|
-
|
|
141
|
-
|
|
146
|
+
listProcesses: {},
|
|
147
|
+
streamProcessOutput: {},
|
|
148
|
+
listOtherProcesses: {},
|
|
149
|
+
watchOtherProcessOutput: {},
|
|
142
150
|
deployMachineFromBrowser: {},
|
|
143
151
|
deployMachine: {},
|
|
144
152
|
}),
|
|
@@ -153,26 +161,25 @@ export const MachineController = getSyncedController(SocketFunction.register(
|
|
|
153
161
|
reads: {},
|
|
154
162
|
});
|
|
155
163
|
|
|
156
|
-
let callbacks = new Map<string, (data: string
|
|
157
|
-
export async function
|
|
164
|
+
let callbacks = new Map<string, (data: string) => Promise<void>>();
|
|
165
|
+
export async function watchProcessOutput(config: {
|
|
158
166
|
nodeId: string;
|
|
159
|
-
|
|
160
|
-
|
|
167
|
+
folder: string;
|
|
168
|
+
launchId: string;
|
|
161
169
|
callbackId: string;
|
|
162
|
-
|
|
163
|
-
onData: (data: string, config?: { reset?: boolean }) => Promise<void>;
|
|
170
|
+
onData: (data: string) => Promise<void>;
|
|
164
171
|
}) {
|
|
165
172
|
let callbackId = config.callbackId;
|
|
166
173
|
callbacks.set(callbackId, config.onData);
|
|
167
|
-
await MachineController(SocketFunction.browserNodeId()).
|
|
174
|
+
await MachineController(SocketFunction.browserNodeId()).watchOtherProcessOutput.promise({
|
|
168
175
|
nodeId: config.nodeId,
|
|
169
|
-
|
|
170
|
-
|
|
176
|
+
folder: config.folder,
|
|
177
|
+
launchId: config.launchId,
|
|
171
178
|
callbackId,
|
|
172
179
|
});
|
|
173
180
|
}
|
|
174
181
|
|
|
175
|
-
export async function
|
|
182
|
+
export async function stopWatchingProcessOutput(config: {
|
|
176
183
|
callbackId: string;
|
|
177
184
|
}) {
|
|
178
185
|
let callbackId = config.callbackId;
|
|
@@ -180,19 +187,15 @@ export async function stopWatchingScreenOutput(config: {
|
|
|
180
187
|
}
|
|
181
188
|
class MachineControllerClientBase {
|
|
182
189
|
public async onScreenOutput(config: {
|
|
183
|
-
|
|
184
|
-
index: number;
|
|
190
|
+
launchId: string;
|
|
185
191
|
data: string;
|
|
186
|
-
reset?: boolean;
|
|
187
192
|
callbackId: string;
|
|
188
193
|
}): Promise<void> {
|
|
189
194
|
let forwardToNodeId = forwardedCallbacks.get(config.callbackId);
|
|
190
195
|
if (forwardToNodeId) {
|
|
191
196
|
await MachineControllerClient.nodes[forwardToNodeId].onScreenOutput({
|
|
192
|
-
|
|
193
|
-
index: config.index,
|
|
197
|
+
launchId: config.launchId,
|
|
194
198
|
data: config.data,
|
|
195
|
-
reset: config.reset,
|
|
196
199
|
callbackId: config.callbackId,
|
|
197
200
|
});
|
|
198
201
|
return;
|
|
@@ -202,7 +205,7 @@ class MachineControllerClientBase {
|
|
|
202
205
|
if (!callback) {
|
|
203
206
|
throw new Error(`Callback ${config.callbackId} not found (likely removed)`);
|
|
204
207
|
}
|
|
205
|
-
await callback(config.data
|
|
208
|
+
await callback(config.data);
|
|
206
209
|
}
|
|
207
210
|
}
|
|
208
211
|
// NOTE: THis is secure, because callbackId is random.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { SERVICE_FOLDER, ServiceParameters } from "./machineSchema";
|
|
5
|
+
|
|
6
|
+
// One log file per process, appended to until it passes LOG_SIZE_LIMIT, then emptied wholesale. Watchers see the file shrink and re-seek to the start. A watcher attaching in the moment right after a reset sees an empty log, which at this size is rare enough to be worth how much simpler this is than rolling files.
|
|
7
|
+
const LOG_SIZE_LIMIT = 10 * 1024 * 1024;
|
|
8
|
+
// Checked every this many lines, so the size check costs nothing per line
|
|
9
|
+
const LOG_SIZE_CHECK_LINES = 1000;
|
|
10
|
+
/** How long a dead process's log is kept before it is deleted. */
|
|
11
|
+
export const DEAD_PROCESS_RETENTION = 3 * 24 * 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
const PROCESS_FOLDER = "processes";
|
|
14
|
+
const RECORD_SUFFIX = ".json";
|
|
15
|
+
const LOG_SUFFIX = ".log";
|
|
16
|
+
const PIPE_SCRIPT_SUFFIX = ".pipe.sh";
|
|
17
|
+
const TAIL_SCRIPT_SUFFIX = ".tail.sh";
|
|
18
|
+
|
|
19
|
+
/** One launch of one service instance: the process, the configuration it was launched with, and its output. Written when the process is launched, and updated only to record its pid and then its death. */
|
|
20
|
+
export type ProcessRecord = {
|
|
21
|
+
launchId: string;
|
|
22
|
+
/** The instance folder this process runs out of. A release's future process shares its canonical instance's folder, so this is what owns the logs, not the tmux session name. */
|
|
23
|
+
folder: string;
|
|
24
|
+
/** The tmux session, which a takeover renames from the future name to the canonical one */
|
|
25
|
+
screenName: string;
|
|
26
|
+
serviceId: string;
|
|
27
|
+
serviceKey: string;
|
|
28
|
+
/** Which instance of the service this is, on this machine */
|
|
29
|
+
index: number;
|
|
30
|
+
machineId: string;
|
|
31
|
+
/** The start time the OS reports for the pid, which is also half of the launchId */
|
|
32
|
+
startTime: number;
|
|
33
|
+
/** When we first noticed the process was gone. Absent while it is running. */
|
|
34
|
+
deadTime?: number;
|
|
35
|
+
pid?: number;
|
|
36
|
+
/** The parameters it was launched with, with template variables already resolved */
|
|
37
|
+
parameters: ServiceParameters;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function getProcessLogPath(folder: string, launchId: string): string {
|
|
41
|
+
return path.join(folder, PROCESS_FOLDER, launchId + LOG_SUFFIX);
|
|
42
|
+
}
|
|
43
|
+
function getRecordPath(folder: string, launchId: string): string {
|
|
44
|
+
return path.join(folder, PROCESS_FOLDER, launchId + RECORD_SUFFIX);
|
|
45
|
+
}
|
|
46
|
+
// The scripts live beside the log so a process's entire footprint is one set of files, deleted together
|
|
47
|
+
export function getPipeScriptPath(folder: string, launchId: string): string {
|
|
48
|
+
return path.join(folder, PROCESS_FOLDER, launchId + PIPE_SCRIPT_SUFFIX);
|
|
49
|
+
}
|
|
50
|
+
export function getTailScriptPath(folder: string, launchId: string): string {
|
|
51
|
+
return path.join(folder, PROCESS_FOLDER, launchId + TAIL_SCRIPT_SUFFIX);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A process identifies itself: its pid, plus the start time the OS reports for it. The pid alone is reused, but a pid running since a specific second is one process, and both halves can be re-checked against the machine at any time instead of trusted from our own bookkeeping. */
|
|
55
|
+
export function createLaunchId(pid: string, startTime: number): string {
|
|
56
|
+
return `${pid}-${startTime}`;
|
|
57
|
+
}
|
|
58
|
+
export function parseLaunchId(launchId: string): { pid: string; startTime: number } {
|
|
59
|
+
let separator = launchId.lastIndexOf("-");
|
|
60
|
+
return {
|
|
61
|
+
pid: launchId.slice(0, separator),
|
|
62
|
+
startTime: parseInt(launchId.slice(separator + 1)) || 0,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The shell a screen's output is piped through: appends every line, and empties the file once it grows past the limit. */
|
|
67
|
+
export function getLogPipeScript(logPath: string): string {
|
|
68
|
+
return `#!/bin/bash
|
|
69
|
+
line_count=0
|
|
70
|
+
while IFS= read -r line; do
|
|
71
|
+
echo "$line" >> "${logPath}"
|
|
72
|
+
((line_count++))
|
|
73
|
+
if (( line_count % ${LOG_SIZE_CHECK_LINES} == 0 )); then
|
|
74
|
+
size=$(stat -c%s "${logPath}" 2>/dev/null || stat -f%z "${logPath}" 2>/dev/null || echo 0)
|
|
75
|
+
if [ "$size" -gt ${LOG_SIZE_LIMIT} ]; then
|
|
76
|
+
: > "${logPath}"
|
|
77
|
+
fi
|
|
78
|
+
fi
|
|
79
|
+
done`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Streams everything appended to a log after the given offset. A file smaller than our offset was emptied by the size limit, so we start over from the front of it. */
|
|
83
|
+
export function getLogTailScript(logPath: string): string {
|
|
84
|
+
return `#!/bin/bash
|
|
85
|
+
CURRENT_POS="$1"
|
|
86
|
+
|
|
87
|
+
while true; do
|
|
88
|
+
sleep 0.25
|
|
89
|
+
if [ -f "${logPath}" ]; then
|
|
90
|
+
SIZE=$(stat -c%s "${logPath}" 2>/dev/null || stat -f%z "${logPath}" 2>/dev/null || echo 0)
|
|
91
|
+
if [ "$SIZE" -lt "$CURRENT_POS" ]; then
|
|
92
|
+
CURRENT_POS=0
|
|
93
|
+
fi
|
|
94
|
+
if [ "$SIZE" -gt "$CURRENT_POS" ]; then
|
|
95
|
+
tail -c +$((CURRENT_POS + 1)) "${logPath}" | head -c $((SIZE - CURRENT_POS))
|
|
96
|
+
CURRENT_POS=$SIZE
|
|
97
|
+
fi
|
|
98
|
+
fi
|
|
99
|
+
done`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function writeProcessRecord(record: ProcessRecord): Promise<void> {
|
|
103
|
+
await fs.promises.mkdir(path.join(record.folder, PROCESS_FOLDER), { recursive: true });
|
|
104
|
+
await fs.promises.writeFile(getRecordPath(record.folder, record.launchId), JSON.stringify(record));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function readFolderRecords(folder: string): Promise<ProcessRecord[]> {
|
|
108
|
+
let processFolder = path.join(folder, PROCESS_FOLDER);
|
|
109
|
+
let files: string[];
|
|
110
|
+
try {
|
|
111
|
+
files = await fs.promises.readdir(processFolder);
|
|
112
|
+
} catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
let records: ProcessRecord[] = [];
|
|
116
|
+
for (let file of files) {
|
|
117
|
+
if (!file.endsWith(RECORD_SUFFIX)) continue;
|
|
118
|
+
records.push(JSON.parse(await fs.promises.readFile(path.join(processFolder, file), "utf8")) as ProcessRecord);
|
|
119
|
+
}
|
|
120
|
+
return records;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Every process this machine has a record of, running or dead, across every service. */
|
|
124
|
+
export async function listProcessRecords(): Promise<ProcessRecord[]> {
|
|
125
|
+
let root = os.homedir() + "/" + SERVICE_FOLDER;
|
|
126
|
+
let instanceFolders: string[];
|
|
127
|
+
try {
|
|
128
|
+
instanceFolders = await fs.promises.readdir(root);
|
|
129
|
+
} catch {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
let records: ProcessRecord[] = [];
|
|
133
|
+
for (let instanceFolder of instanceFolders) {
|
|
134
|
+
records.push(...await readFolderRecords(path.join(root, instanceFolder)));
|
|
135
|
+
}
|
|
136
|
+
return records;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function removeProcessRecord(record: ProcessRecord): Promise<void> {
|
|
140
|
+
let files = [
|
|
141
|
+
getRecordPath(record.folder, record.launchId),
|
|
142
|
+
getProcessLogPath(record.folder, record.launchId),
|
|
143
|
+
getPipeScriptPath(record.folder, record.launchId),
|
|
144
|
+
getTailScriptPath(record.folder, record.launchId),
|
|
145
|
+
];
|
|
146
|
+
for (let file of files) {
|
|
147
|
+
try {
|
|
148
|
+
await fs.promises.unlink(file);
|
|
149
|
+
} catch {
|
|
150
|
+
// Already gone, which is the state we wanted
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Marks every record whose process is gone as dead, and deletes the ones that have been dead past the retention. */
|
|
156
|
+
export async function syncProcessRecords(config: {
|
|
157
|
+
isAlive: (record: ProcessRecord) => Promise<boolean>;
|
|
158
|
+
now: number;
|
|
159
|
+
}): Promise<void> {
|
|
160
|
+
for (let record of await listProcessRecords()) {
|
|
161
|
+
if (record.deadTime === undefined && !await config.isAlive(record)) {
|
|
162
|
+
record.deadTime = config.now;
|
|
163
|
+
console.log(`Process ${record.launchId} (${record.screenName}) is no longer running, marking it dead. Its log is deleted at ${new Date(record.deadTime + DEAD_PROCESS_RETENTION).toISOString()}`);
|
|
164
|
+
await writeProcessRecord(record);
|
|
165
|
+
}
|
|
166
|
+
if (record.deadTime !== undefined && config.now - record.deadTime > DEAD_PROCESS_RETENTION) {
|
|
167
|
+
console.log(`Deleting the log of process ${record.launchId} (${record.screenName}), dead since ${new Date(record.deadTime).toISOString()}`);
|
|
168
|
+
await removeProcessRecord(record);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|