Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/hub/hub.ts
5820 views
1
//########################################################################
2
// This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
// License: MS-RSL – see LICENSE.md for details
4
//########################################################################
5
6
// This is the CoCalc Global HUB. It runs as a daemon, sitting in the
7
// middle of the action, connected to potentially thousands of clients,
8
// many Sage sessions, and PostgreSQL database.
9
10
import { callback } from "awaiting";
11
import blocked from "blocked";
12
import { spawn } from "child_process";
13
import { program as commander, Option } from "commander";
14
15
import basePath from "@cocalc/backend/base-path";
16
import {
17
pghost as DEFAULT_DB_HOST,
18
pgdatabase as DEFAULT_DB_NAME,
19
pguser as DEFAULT_DB_USER,
20
pgConcurrentWarn as DEFAULT_DB_CONCURRENT_WARN,
21
hubHostname as DEFAULT_HUB_HOSTNAME,
22
agentPort as DEFAULT_AGENT_PORT,
23
} from "@cocalc/backend/data";
24
import { trimLogFileSize } from "@cocalc/backend/logger";
25
import port from "@cocalc/backend/port";
26
import { init_start_always_running_projects } from "@cocalc/database/postgres/always-running";
27
import { load_server_settings_from_env } from "@cocalc/database/settings/server-settings";
28
import {
29
initConatApi,
30
initConatChangefeedServer,
31
initConatPersist,
32
loadConatConfiguration,
33
} from "@cocalc/server/conat";
34
import { initConatServer } from "@cocalc/server/conat/socketio";
35
import { init_passport } from "@cocalc/server/hub/auth";
36
import { initialOnPremSetup } from "@cocalc/server/initial-onprem-setup";
37
import initHandleMentions from "@cocalc/server/mentions/handle";
38
import initMessageMaintenance from "@cocalc/server/messages/maintenance";
39
import { start as startHubRegister } from "@cocalc/server/metrics/hub_register";
40
import initProjectControl, {
41
COCALC_MODES,
42
} from "@cocalc/server/projects/control";
43
import initIdleTimeout from "@cocalc/server/projects/control/stop-idle-projects";
44
import initNewProjectPoolMaintenanceLoop from "@cocalc/server/projects/pool/maintain";
45
import initPurchasesMaintenanceLoop from "@cocalc/server/purchases/maintenance";
46
import initEphemeralMaintenance from "@cocalc/server/ephemeral-maintenance";
47
import initSalesloftMaintenance from "@cocalc/server/salesloft/init";
48
import { stripe_sync } from "@cocalc/server/stripe/sync";
49
import { callback2, retry_until_success } from "@cocalc/util/async-utils";
50
import { set_agent_endpoint } from "./health-checks";
51
import { getLogger } from "./logger";
52
import initDatabase, { database } from "./servers/database";
53
import initExpressApp from "./servers/express-app";
54
55
import initHttpRedirect from "./servers/http-redirect";
56
57
import { addErrorListeners } from "@cocalc/server/metrics/error-listener";
58
import * as MetricsRecorder from "@cocalc/server/metrics/metrics-recorder";
59
import { migrateBookmarksToConat } from "./migrate-bookmarks";
60
61
// Logger tagged with 'hub' for this file.
62
const logger = getLogger("hub");
63
64
// program gets populated with the command line options below.
65
let program: { [option: string]: any } = {};
66
export { program };
67
68
const REGISTER_INTERVAL_S = 20;
69
70
async function reset_password(email_address: string): Promise<void> {
71
try {
72
await callback2(database.reset_password, { email_address });
73
logger.info(`Password changed for ${email_address}`);
74
} catch (err) {
75
logger.info(`Error resetting password -- ${err}`);
76
}
77
}
78
79
// This calculates and updates the statistics for the /stats endpoint.
80
// It's important that we call this periodically, because otherwise the /stats data is outdated.
81
async function init_update_stats(): Promise<void> {
82
logger.info("init updating stats periodically");
83
const update = () => callback2(database.get_stats);
84
// Do it every minute:
85
setInterval(() => update(), 60000);
86
// Also do it once now:
87
await update();
88
}
89
90
// This calculates and updates the site_license_usage_log.
91
// It's important that we call this periodically, if we want
92
// to be able to monitor site license usage. This is enabled
93
// by default only for dev mode (so for development).
94
async function init_update_site_license_usage_log() {
95
logger.info("init updating site license usage log periodically");
96
const update = async () => await database.update_site_license_usage_log();
97
setInterval(update, 31000);
98
await update();
99
}
100
101
async function initMetrics() {
102
logger.info("Initializing Metrics Recorder...");
103
MetricsRecorder.init();
104
return {
105
metric_blocked: MetricsRecorder.new_counter(
106
"blocked_ms_total",
107
'accumulates the "blocked" time in the hub [ms]',
108
),
109
};
110
}
111
112
async function startServer(): Promise<void> {
113
logger.info("start_server");
114
115
logger.info(`basePath='${basePath}'`);
116
logger.info(
117
`database: name="${program.databaseName}" nodes="${program.databaseNodes}" user="${program.databaseUser}"`,
118
);
119
120
const { metric_blocked } = await initMetrics();
121
122
// Log anything that blocks the CPU for more than ~100ms -- see https://github.com/tj/node-blocked
123
blocked((ms: number) => {
124
if (ms > 100) {
125
metric_blocked.inc(ms);
126
}
127
// record that something blocked:
128
if (ms > 100) {
129
logger.debug(`BLOCKED for ${ms}ms`);
130
}
131
});
132
133
// Wait for database connection to work. Everything requires this.
134
await retry_until_success({
135
f: async () => await callback2(database.connect),
136
start_delay: 1000,
137
max_delay: 10000,
138
});
139
logger.info("connected to database.");
140
141
if (program.updateDatabaseSchema) {
142
logger.info("Update database schema");
143
await callback2(database.update_schema);
144
145
// in those cases where we initialize the database upon startup
146
// (essentially only relevant for kucalc's hub-websocket)
147
if (program.mode === "kucalc") {
148
// and for on-prem setups, also initialize the admin account, set a registration token, etc.
149
await initialOnPremSetup(database);
150
}
151
}
152
153
// set server settings based on environment variables
154
await load_server_settings_from_env(database);
155
156
if (program.agentPort) {
157
logger.info("Configure agent port");
158
set_agent_endpoint(program.agentPort, program.hostname);
159
}
160
161
// Mentions
162
if (program.mentions) {
163
logger.info("enabling handling of mentions...");
164
initHandleMentions();
165
logger.info("enabling handling of messaging...");
166
initMessageMaintenance();
167
}
168
169
// Project control
170
logger.info("initializing project control...");
171
const projectControl = initProjectControl(program.mode);
172
// used for nextjs hot module reloading dev server
173
process.env["COCALC_MODE"] = program.mode;
174
175
if (program.mode != "kucalc" && program.conatServer) {
176
// We handle idle timeout of projects.
177
// This can be disabled via COCALC_NO_IDLE_TIMEOUT.
178
// This only uses the admin-configurable settings field of projects
179
// in the database and isn't aware of licenses or upgrades.
180
initIdleTimeout(projectControl);
181
}
182
183
// This loads from the database credentials to use Conat.
184
await loadConatConfiguration();
185
186
if (program.conatRouter) {
187
// launch standalone socketio websocket server (no http server)
188
await initConatServer({ kucalc: program.mode == "kucalc" });
189
}
190
191
if (program.conatApi || program.conatServer) {
192
await initConatApi();
193
await initConatChangefeedServer();
194
}
195
196
if (program.conatPersist || program.conatServer) {
197
await initConatPersist();
198
}
199
200
if (program.conatServer) {
201
if (program.mode == "single-user" && process.env.USER == "user") {
202
// Definitely in dev mode, probably on cocalc.com in a project, so we kill
203
// all the running projects when starting the hub:
204
// Whenever we start the dev server, we just assume
205
// all projects are stopped, since assuming they are
206
// running when they are not is bad. Something similar
207
// is done in cocalc-docker.
208
logger.info("killing all projects...");
209
await callback2(database._query, {
210
safety_check: false,
211
query: 'update projects set state=\'{"state":"opened"}\'',
212
});
213
await spawn("pkill", ["-f", "node_modules/.bin/cocalc-project"]);
214
215
// Also, unrelated to killing projects, for purposes of developing
216
// custom software images, we inject a couple of random nonsense entries
217
// into the table in the DB:
218
logger.info("inserting random nonsense compute images in database");
219
await callback2(database.insert_random_compute_images);
220
}
221
222
if (program.mode != "kucalc") {
223
await init_update_stats();
224
await init_update_site_license_usage_log();
225
// This is async but runs forever, so don't wait for it.
226
logger.info("init starting always running projects");
227
init_start_always_running_projects(database);
228
}
229
}
230
231
if (
232
program.conatServer ||
233
program.proxyServer ||
234
program.nextServer ||
235
program.conatApi
236
) {
237
const { router, httpServer } = await initExpressApp({
238
isPersonal: program.personal,
239
projectControl,
240
conatServer: !!program.conatServer,
241
proxyServer: true, // always
242
nextServer: !!program.nextServer,
243
cert: program.httpsCert,
244
key: program.httpsKey,
245
});
246
247
// The express app create via initExpressApp above **assumes** that init_passport is done
248
// or complains a lot. This is obviously not really necessary, but we leave it for now.
249
await callback2(init_passport, {
250
router,
251
database,
252
host: program.hostname,
253
});
254
255
logger.info(`starting webserver listening on ${program.hostname}:${port}`);
256
await callback(httpServer.listen.bind(httpServer), port, program.hostname);
257
258
if (port == 443 && program.httpsCert && program.httpsKey) {
259
// also start a redirect from port 80 to port 443.
260
await initHttpRedirect(program.hostname);
261
}
262
263
logger.info(
264
"Starting registering periodically with the database and updating a health check...",
265
);
266
267
// register the hub with the database periodically, and
268
// also confirms that database is working.
269
await callback2(startHubRegister, {
270
database,
271
host: program.hostname,
272
port,
273
interval_s: REGISTER_INTERVAL_S,
274
});
275
276
const protocol = program.httpsKey ? "https" : "http";
277
const target = `${protocol}://${program.hostname}:${port}${basePath}`;
278
279
const msg = `Started HUB!\n\n-----------\n\n The following URL *might* work: ${target}\n\n\nPORT=${port}\nBASE_PATH=${basePath}\nPROTOCOL=${protocol}\n\n${
280
basePath.length <= 1
281
? ""
282
: "If you are developing cocalc inside of cocalc, take the URL of the host cocalc\nand append " +
283
basePath +
284
" to it."
285
}\n\n-----------\n\n`;
286
logger.info(msg);
287
console.log(msg);
288
}
289
290
if (program.all || program.mentions) {
291
// kucalc: for now we just have the hub-mentions servers
292
// do the new project pool maintenance, since there is only
293
// one hub-stats.
294
// On non-cocalc it'll get done by *the* hub because of program.all.
295
initNewProjectPoolMaintenanceLoop();
296
// Starts periodic maintenance on pay-as-you-go purchases, e.g., quota
297
// upgrades of projects.
298
initPurchasesMaintenanceLoop();
299
initEphemeralMaintenance();
300
initSalesloftMaintenance();
301
// Migrate bookmarks from database to conat (runs once at startup)
302
migrateBookmarksToConat().catch((err) => {
303
logger.error("Failed to migrate bookmarks to conat:", err);
304
});
305
setInterval(trimLogFileSize, 1000 * 60 * 3);
306
}
307
308
addErrorListeners();
309
}
310
311
//############################################
312
// Process command line arguments
313
//############################################
314
async function main(): Promise<void> {
315
commander
316
.name("cocalc-hub-server")
317
.usage("options")
318
.addOption(
319
new Option(
320
"--mode [string]",
321
`REQUIRED mode in which to run CoCalc (${COCALC_MODES.join(
322
", ",
323
)}) - or set COCALC_MODE env var`,
324
).choices(COCALC_MODES as any as string[]),
325
)
326
.option(
327
"--all",
328
"runs all of the servers: websocket, proxy, next (so you don't have to pass all those opts separately), and also mentions updator and updates db schema on startup; use this in situations where there is a single hub that serves everything (instead of a microservice situation like kucalc)",
329
)
330
.option(
331
"--conat-server",
332
"run a hub that provides a single-core conat server (i.e., conat-router but integrated with the http server), api, and persistence, along with an http server. This is for dev and small deployments of cocalc (and if given, do not bother with --conat-[core|api|persist] below.)",
333
)
334
.option(
335
"--conat-router",
336
"run a hub that provides the core conat communication layer server over a websocket (but not http server).",
337
)
338
.option(
339
"--conat-api",
340
"run a hub that connect to conat-router and provides the standard conat API services, e.g., basic api, LLM's, changefeeds, http file upload/download, etc. There must be at least one of these. You can increase or decrease the number of these servers with no coordination needed.",
341
)
342
.option(
343
"--conat-persist",
344
"run a hub that connects to conat-router and provides persistence for streams (e.g., key for sync editing). There must be at least one of these, and they need access to common shared disk to store sqlite files. Only one server uses a given sqlite file at a time. You can increase or decrease the number of these servers with no coordination needed.",
345
)
346
.option("--proxy-server", "run a proxy server in this process")
347
.option(
348
"--next-server",
349
"run a nextjs server (landing pages, share server, etc.) in this process",
350
)
351
.option(
352
"--https-key [string]",
353
"serve over https. argument should be a key filename (both https-key and https-cert must be specified)",
354
)
355
.option(
356
"--https-cert [string]",
357
"serve over https. argument should be a cert filename (both https-key and https-cert must be specified)",
358
)
359
.option(
360
"--agent-port <n>",
361
`port for HAProxy agent-check (default: ${DEFAULT_AGENT_PORT}; 0 means "do not start")`,
362
(n) => parseInt(n),
363
DEFAULT_AGENT_PORT,
364
)
365
.option(
366
"--hostname [string]",
367
`host of interface to bind to (default: "${DEFAULT_HUB_HOSTNAME}")`,
368
DEFAULT_HUB_HOSTNAME,
369
)
370
.option(
371
"--database-nodes <string,string,...>",
372
`database address (default: '${DEFAULT_DB_HOST}')`,
373
DEFAULT_DB_HOST,
374
)
375
.option(
376
"--database-name [string]",
377
`Database name to use (default: "${DEFAULT_DB_NAME}")`,
378
DEFAULT_DB_NAME,
379
)
380
.option(
381
"--database-user [string]",
382
`Database username to use (default: "${DEFAULT_DB_USER}")`,
383
DEFAULT_DB_USER,
384
)
385
.option("--passwd [email_address]", "Reset password of given user", "")
386
.option(
387
"--update-database-schema",
388
"If specified, updates database schema on startup (always happens when mode is not kucalc).",
389
)
390
.option(
391
"--stripe-sync",
392
"Sync stripe subscriptions to database for all users with stripe id",
393
"yes",
394
)
395
.option(
396
"--update-stats",
397
"Calculates the statistics for the /stats endpoint and stores them in the database",
398
"yes",
399
)
400
.option("--delete-expired", "Delete expired data from the database", "yes")
401
.option(
402
"--blob-maintenance",
403
"Do blob-related maintenance (dump to tarballs, offload to gcloud)",
404
"yes",
405
)
406
.option(
407
"--mentions",
408
"if given, periodically handle mentions; on kucalc there is only one of these. It also managed the new project pool. Maybe this should be renamed --singleton!",
409
)
410
.option(
411
"--test",
412
"terminate after setting up the hub -- used to test if it starts up properly",
413
)
414
.option(
415
"--db-concurrent-warn <n>",
416
`be very unhappy if number of concurrent db requests exceeds this (default: ${DEFAULT_DB_CONCURRENT_WARN})`,
417
(n) => parseInt(n),
418
DEFAULT_DB_CONCURRENT_WARN,
419
)
420
.option(
421
"--personal",
422
"run VERY UNSAFE: there is only one user and no authentication",
423
)
424
.parse(process.argv);
425
// Everywhere else in our code, we just refer to program.[options] since we
426
// wrote this code against an ancient version of commander.
427
const opts = commander.opts();
428
for (const name in opts) {
429
program[name] = opts[name];
430
}
431
if (!program.mode) {
432
program.mode = process.env.COCALC_MODE;
433
if (!program.mode) {
434
throw Error(
435
`the --mode option must be specified or the COCALC_MODE env var set to one of ${COCALC_MODES.join(
436
", ",
437
)}`,
438
);
439
process.exit(1);
440
}
441
}
442
if (program.all) {
443
program.conatServer =
444
program.proxyServer =
445
program.nextServer =
446
program.mentions =
447
program.updateDatabaseSchema =
448
true;
449
}
450
if (process.env.COCALC_DISABLE_NEXT) {
451
program.nextServer = false;
452
}
453
454
//console.log("got opts", opts);
455
456
try {
457
// Everything we do here requires the database to be initialized. Once
458
// this is called, require('@cocalc/database/postgres/database').default() is a valid db
459
// instance that can be used.
460
initDatabase({
461
host: program.databaseNodes,
462
database: program.databaseName,
463
user: program.databaseUser,
464
concurrent_warn: program.dbConcurrentWarn,
465
});
466
467
if (program.passwd) {
468
logger.debug("Resetting password");
469
await reset_password(program.passwd);
470
process.exit();
471
} else if (program.stripeSync) {
472
logger.debug("Stripe sync");
473
await stripe_sync({ database, logger: logger });
474
process.exit();
475
} else if (program.deleteExpired) {
476
await callback2(database.delete_expired, {
477
count_only: false,
478
});
479
process.exit();
480
} else if (program.blobMaintenance) {
481
await callback2(database.blob_maintenance);
482
process.exit();
483
} else if (program.updateStats) {
484
await callback2(database.get_stats);
485
process.exit();
486
} else {
487
await startServer();
488
}
489
} catch (err) {
490
console.log(err);
491
logger.error("Error -- ", err);
492
process.exit(1);
493
}
494
}
495
496
main();
497
498