1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
|
import * as Archiver from 'archiver';
import { cyan, green, red, yellow } from 'colors';
import { createWriteStream, readFileSync, readdirSync, statSync, unlinkSync } from 'fs';
import { resolve } from 'path';
import { get } from 'request-promise';
import { rimraf } from 'rimraf';
import { launchServer, onWindows } from '..';
import { Utils } from '../../Utils';
import { ServerUtils } from '../../ServerUtils';
import { Email, pathFromRoot } from '../ActionUtilities';
import { MessageStore } from '../Message';
import { WebSocket } from '../websocket';
import { AppliedSessionAgent, ExitHandler } from './Session/agents/applied_session_agent';
import { Monitor } from './Session/agents/monitor';
import { ErrorLike, MessageHandler } from './Session/agents/promisified_ipc_manager';
import { ServerWorker } from './Session/agents/server_worker';
/**
* If we're the monitor (master) thread, we should launch the monitor logic for the session.
* Otherwise, we must be on a worker thread that was spawned *by* the monitor (master) thread, and thus
* our job should be to run the server.
*/
export class DashSessionAgent extends AppliedSessionAgent {
private readonly signature = '-Dash Server Session Manager';
private readonly releaseDesktop = pathFromRoot('../../Desktop');
public static notificationRecipient = 'browndashptc@gmail.com';
/**
* The core method invoked when the single master thread is initialized.
* Installs event hooks, repl commands and additional IPC listeners.
*/
protected async initializeMonitor(monitor: Monitor): Promise<string> {
const sessionKey = Utils.GenerateGuid();
await this.dispatchSessionPassword(sessionKey);
monitor.addReplCommand('pull', [], () => monitor.exec('git pull'));
monitor.addReplCommand('solr', [/start|stop|index/], this.executeSolrCommand);
monitor.addReplCommand('backup', [], this.backup);
monitor.addReplCommand('debug', [/\S+@\S+/], async ([to]) => this.dispatchZippedDebugBackup(to));
monitor.on('backup', this.backup);
monitor.on('debug', async ({ to }) => this.dispatchZippedDebugBackup(to));
monitor.on('delete', WebSocket.doDelete);
monitor.coreHooks.onCrashDetected(this.dispatchCrashReport);
return sessionKey;
}
/**
* The core method invoked when a server worker thread is initialized.
* Installs logic to be executed when the server worker dies.
*/
protected async initializeServerWorker(): Promise<ServerWorker> {
const worker = ServerWorker.Create(launchServer); // server initialization delegated to worker
worker.addExitHandler(this.notifyClient);
return worker;
}
/**
* Prepares the body of the email with instructions on restoring the transmitted remote database backup locally.
*/
private _remoteDebugInstructions: string | undefined;
private generateDebugInstructions = (zipName: string, target: string): string => {
if (!this._remoteDebugInstructions) {
this._remoteDebugInstructions = readFileSync(resolve(__dirname, './templates/remote_debug_instructions.txt'), { encoding: 'utf8' });
}
return this._remoteDebugInstructions
.replace(/__zipname__/, zipName)
.replace(/__target__/, target)
.replace(/__signature__/, this.signature);
};
/**
* Prepares the body of the email with information regarding a crash event.
*/
private _crashInstructions: string | undefined;
private generateCrashInstructions({ name, message, stack }: ErrorLike): string {
if (!this._crashInstructions) {
this._crashInstructions = readFileSync(resolve(__dirname, './templates/crash_instructions.txt'), { encoding: 'utf8' });
}
return this._crashInstructions
.replace(/__name__/, name || '[no error name found]')
.replace(/__message__/, message || '[no error message found]')
.replace(/__stack__/, stack || '[no error stack found]')
.replace(/__signature__/, this.signature);
}
/**
* This sends a pseudorandomly generated guid to the configuration's recipients, allowing them alone
* to kill the server via the /kill/:key route.
*/
private dispatchSessionPassword = async (sessionKey: string): Promise<void> => {
const { mainLog } = this.sessionMonitor;
const { notificationRecipient } = DashSessionAgent;
mainLog(green('dispatching session key...'));
const error = await Email.dispatch({
to: notificationRecipient,
subject: 'Dash Release Session Admin Authentication Key',
content: [`Here's the key for this session (started @ ${new Date().toUTCString()}):`, sessionKey, this.signature].join('\n\n'),
});
if (error) {
this.sessionMonitor.mainLog(red(`dispatch failure @ ${notificationRecipient} (${yellow(error.message)})`));
mainLog(red('distribution of session key experienced errors'));
} else {
mainLog(green('successfully distributed session key to recipients'));
}
};
/**
* This sends an email with the generated crash report.
*/
private dispatchCrashReport: MessageHandler<{ error: ErrorLike }> = async ({ error: crashCause }) => {
const { mainLog } = this.sessionMonitor;
const { notificationRecipient } = DashSessionAgent;
const error = await Email.dispatch({
to: notificationRecipient,
subject: 'Dash Web Server Crash',
content: this.generateCrashInstructions(crashCause),
});
if (error) {
this.sessionMonitor.mainLog(red(`dispatch failure @ ${notificationRecipient} ${yellow(`(${error.message})`)}`));
mainLog(red('distribution of crash notification experienced errors'));
} else {
mainLog(green('successfully distributed crash notification to recipients'));
}
};
/**
* Logic for interfacing with Solr. Either starts it,
* stops it, or rebuilds its indices.
*/
private executeSolrCommand = async (args: string[]): Promise<void> => {
const { exec, mainLog } = this.sessionMonitor;
const action = args[0];
if (action === 'index') {
exec('npx ts-node ./updateSearch.ts', { cwd: pathFromRoot('./src/server') });
} else {
const command = `${onWindows ? 'solr.cmd' : 'solr'} ${args[0] === 'start' ? 'start' : 'stop -p 8983'}`;
await exec(command, { cwd: './solr-8.3.1/bin' });
try {
await get('http://localhost:8983');
mainLog(green('successfully connected to 8983 after running solr initialization'));
} catch {
mainLog(red('unable to connect at 8983 after running solr initialization'));
}
}
};
/**
* Broadcast to all clients that their connection
* is no longer valid, and explain why / what to expect.
*/
private notifyClient: ExitHandler = reason => {
const { _socket } = WebSocket;
if (_socket) {
const message = typeof reason === 'boolean' ? (reason ? 'exit' : 'temporary') : 'crash';
ServerUtils.Emit(_socket, MessageStore.ConnectionTerminated, message);
}
};
/**
* Performs a backup of the database, saved to the desktop subdirectory.
* This should work as is only on our specific release server.
*/
private backup = async (): Promise<void> => this.sessionMonitor.exec('backup.bat', { cwd: this.releaseDesktop });
/**
* Compress either a brand new backup or the most recent backup and send it
* as an attachment to an email, dispatched to the requested recipient.
* @param mode specifies whether or not to make a new backup before exporting
* @param to the recipient of the email
*/
private async dispatchZippedDebugBackup(to: string): Promise<void> {
const { mainLog } = this.sessionMonitor;
try {
// if desired, complete an immediate backup to send
await this.backup();
mainLog('backup complete');
const backupsDirectory = `${this.releaseDesktop}/backups`;
// sort all backups by their modified time, and choose the most recent one
const target = readdirSync(backupsDirectory)
.map(filename => ({
modifiedTime: statSync(`${backupsDirectory}/${filename}`).mtimeMs,
filename,
}))
.sort((a, b) => b.modifiedTime - a.modifiedTime)[0].filename;
mainLog(`targeting ${target}...`);
// create a zip file and to it, write the contents of the backup directory
const zipName = `${target}.zip`;
const zipPath = `${this.releaseDesktop}/${zipName}`;
const targetPath = `${backupsDirectory}/${target}`;
const output = createWriteStream(zipPath);
const zip = Archiver('zip');
zip.pipe(output);
zip.directory(`${targetPath}/Dash`, false);
await zip.finalize();
mainLog(`zip finalized with size ${statSync(zipPath).size} bytes, saved to ${zipPath}`);
// dispatch the email to the recipient, containing the finalized zip file
const error = await Email.dispatch({
to,
subject: `Remote debug: compressed backup of ${target}...`,
content: this.generateDebugInstructions(zipName, target),
attachments: [{ filename: zipName, path: zipPath }],
});
// since this is intended to be a zero-footprint operation, clean up
// by unlinking both the backup generated earlier in the function and the compressed zip file.
// to generate a persistent backup, just run backup.
unlinkSync(zipPath);
rimraf.sync(targetPath);
// indicate success or failure
mainLog(`${error === null ? green('successfully dispatched') : red('failed to dispatch')} ${zipName} to ${cyan(to)}`);
error && mainLog(red(error.message));
} catch (error: unknown) {
mainLog(red('unable to dispatch zipped backup...'));
mainLog(red((error as { message: string }).message));
}
}
}
|