querysub 0.557.0 → 0.559.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 CHANGED
@@ -1,62 +1,11 @@
1
1
  import fs from "fs";
2
- import os from "os";
3
- import { isNode } from "socket-function/src/misc";
4
2
  import { lazy } from "socket-function/src/caching";
5
3
  import { ArchivesBackblaze } from "sliftutils/storage/backblaze";
6
- import { parseArgsFactory } from "./src/misc/rawParams";
4
+ import { STORAGE_DIR, getDomain, getBackblazePath } from "./src/misc/appPaths";
7
5
  import { Querysub } from "./src/4-querysub/Querysub";
8
6
 
9
- // getSecret always imports us relative to the process cwd, so the cwd is the repo root and this matches what getStorageDir would resolve
10
- 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 (parseArgsFactory is fine to import, as it has no dependencies of its own)
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
7
  const keysArchives = lazy(() => new ArchivesBackblaze({ bucketName: getDomain() }));
46
8
 
47
- export function getBackblazePath() {
48
- let testPaths = [
49
- STORAGE_DIR + "backblaze.json",
50
- os.homedir() + "/backblaze.json",
51
- ];
52
- for (let path of testPaths) {
53
- if (fs.existsSync(path)) {
54
- return path;
55
- }
56
- }
57
- return testPaths[0];
58
- }
59
-
60
9
  export const getCloudflareCreds = lazy(async (): Promise<{ key: string; email: string }> => {
61
10
  let archives = keysArchives();
62
11
  let credsJSON = await archives.get("keys/cloudflare.json");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.557.0",
3
+ "version": "0.559.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -3,7 +3,7 @@ import { Archives } from "./archives";
3
3
  import fs from "fs";
4
4
  import { isNode, timeInMinute } from "socket-function/src/misc";
5
5
  import { isLogBackblaze } from "../config";
6
- import { getBackblazePath } from "../../appSecrets";
6
+ import { getBackblazePath } from "../misc/appPaths";
7
7
  import { ArchivesBackblaze as ArchivesBackblazeBase } from "sliftutils/storage/backblaze";
8
8
  import type { IArchives, ArchivesConfig } from "sliftutils/storage/IArchives";
9
9
 
@@ -50,7 +50,6 @@ import yargs, { check } from "yargs";
50
50
  import { parseArgsFactory } from "../misc/rawParams";
51
51
 
52
52
  import * as typesafecss from "typesafecss";
53
- import "../library-components/urlResetGroups";
54
53
  import { createLocalSchema } from "./schemaHelpers";
55
54
 
56
55
  setTimeout(() => import("./FunctionRunnerTracking"));
@@ -5,6 +5,7 @@ import { css } from "typesafecss";
5
5
  import { sort } from "socket-function/src/misc";
6
6
  import { formatNumber, formatDateTime, formatTime, formatDateTimeDetailed } from "socket-function/src/formatting/format";
7
7
  import { parseHostedUrl, parseBackblazeUrl } from "sliftutils/storage/remoteStorage/remoteConfig";
8
+ import { resolveIntermediateSources, getIntermediateSources } from "sliftutils/storage/remoteStorage/intermediateSources";
8
9
  import { SocketFunction } from "socket-function/SocketFunction";
9
10
  import { FULL_VALID_WINDOW, FULL_ROUTE } from "sliftutils/storage/IArchives";
10
11
  import type { RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig } from "sliftutils/storage/IArchives";
@@ -20,6 +21,7 @@ type Source = HostedConfig | BackblazeConfig;
20
21
  const DEFAULT_HTTPS_PORT = 443;
21
22
  const TAG_COLOR = { h: 210, s: 45, l: 88 };
22
23
  const WARNING_COLOR = { h: 35, s: 90, l: 85 };
24
+ const NOTICE_COLOR = { h: 205, s: 70, l: 88 };
23
25
  const SOURCE_BORDER_COLOR = { h: 0, s: 0, l: 75 };
24
26
  const ACTIVE_COLOR = { h: 130, s: 55, l: 85 };
25
27
  const PAST_COLOR = { h: 0, s: 0, l: 90 };
@@ -454,10 +456,29 @@ class WindowRanges extends qreact.Component<{ sources: Source[]; ownBucketName:
454
456
  }
455
457
  }
456
458
 
459
+ /** Two configs that differ only by switchover windows are not really in conflict - resolving those away is exactly what a client does before using the config, so if the remainder matches, every server is working from the same underlying configuration. The version is ignored: a config gains a version purely by having an intermediate written into it. */
460
+ function getIntermediateOnlyDifference(consensus: RemoteConfig, other: RemoteConfig, now: number): {
461
+ intermediateOnly: boolean;
462
+ /** Undefined when nothing is pending; otherwise when the last switchover window still in the future ends */
463
+ expiresAt?: number;
464
+ } {
465
+ let sourcesOf = (config: RemoteConfig) => JSON.stringify(resolveIntermediateSources(config).sources);
466
+ let intermediateOnly = sourcesOf(consensus) === sourcesOf(other);
467
+ let ends = [...getIntermediateSources(consensus), ...getIntermediateSources(other)].map(x => x.validWindow[1]);
468
+ let pending = ends.filter(end => end > now);
469
+ return {
470
+ intermediateOnly,
471
+ expiresAt: pending.length && Math.max(...pending) || undefined,
472
+ };
473
+ }
474
+
457
475
  class ConflictWarning extends qreact.Component<{ consensus: ConfigVariant; conflict: ConfigConflict }> {
458
476
  render() {
459
477
  let { consensus, conflict } = this.props;
460
478
  let { bucketName, variant } = conflict;
479
+ let now = Querysub.timeDelayed(TIME_REFRESH_INTERVAL);
480
+ let { intermediateOnly, expiresAt } = getIntermediateOnlyDifference(consensus.rawConfig, variant.rawConfig, now);
481
+ let color = intermediateOnly && NOTICE_COLOR || WARNING_COLOR;
461
482
  let consensusKeys = new Set(consensus.sources.map(x => getSourceKey(x, bucketName)));
462
483
  let conflictKeys = new Set(variant.sources.map(x => getSourceKey(x, bucketName)));
463
484
  let extra = variant.sources.filter(x => !consensusKeys.has(getSourceKey(x, bucketName)));
@@ -469,10 +490,15 @@ class ConflictWarning extends qreact.Component<{ consensus: ConfigVariant; confl
469
490
  route {(source.route || FULL_ROUTE).join(" – ")}, valid {formatWindowTime(source.validWindow[0])} → {formatWindowTime(source.validWindow[1])}
470
491
  </div>
471
492
  </div>;
472
- return <div className={css.vbox(6).pad2(10, 8).hsl(WARNING_COLOR.h, WARNING_COLOR.s, WARNING_COLOR.l).bord2(WARNING_COLOR.h, WARNING_COLOR.s, WARNING_COLOR.l - 25)}>
493
+ return <div className={css.vbox(6).pad2(10, 8).hsl(color.h, color.s, color.l).bord2(color.h, color.s, color.l - 25)}>
473
494
  <div className={css.boldStyle}>
474
- ⚠ {bucketName}: {variant.servers.length} server{variant.servers.length === 1 && "" || "s"} report a different routing config (version {String(variant.version ?? "none")}, consensus is version {String(consensus.version ?? "none")})
495
+ {intermediateOnly && "ℹ" || ""} {bucketName}: {variant.servers.length} server{variant.servers.length === 1 && "" || "s"} report a different routing config (version {String(variant.version ?? "none")}, consensus is version {String(consensus.version ?? "none")})
475
496
  </div>
497
+ {intermediateOnly && <div>
498
+ Only the switchover windows differ - with those resolved away, every server is working from the same configuration.
499
+ {expiresAt === undefined && " They are all in the past, so this shouldn't break anything."}
500
+ {expiresAt !== undefined && ` The sources become consistent again once the last one expires, ${formatTime(expiresAt - now)} from now (${formatWindowTimeDetailed(expiresAt)}).`}
501
+ </div>}
476
502
  <div className={css.vbox(2).fontSize(TAG_FONT_SIZE)}>
477
503
  {variant.servers.map(server => <div key={server}>{server}</div>)}
478
504
  </div>
@@ -26,7 +26,7 @@ import { PromiseObj } from "../promise";
26
26
  import path from "path";
27
27
  import { fsExistsAsync } from "../fs";
28
28
  import { ALIVE_WINDOW_FOREVER, ParametersTimelineEntry, syncParametersTimelineFiles } from "./parametersTimeline";
29
- import { getScreenName, getFutureScreenName, getTmuxPrefix, getScreenState, isScreenRunningProcess, getProcessStartTime, runScreenCommand, killScreen, streamProcessOutput } from "./processManager";
29
+ import { getScreenName, getFutureScreenName, getTmuxPrefix, getScreenState, isScreenRunningProcess, getProcessStartTime, ensureProcessRecord, runScreenCommand, killScreen, streamProcessOutput } from "./processManager";
30
30
  import { ProcessRecord, syncProcessRecords, writeProcessRecord, listProcessRecords, getProcessLogPath } from "./processLogs";
31
31
 
32
32
 
@@ -288,6 +288,19 @@ async function ensureFutureStarted(config: FutureConfig) {
288
288
  let existing = config.screenStateMap.get(futureScreenName);
289
289
  if (existing?.isProcessRunning) {
290
290
  config.screenNamesUsed.add(futureScreenName);
291
+ // Started before we restarted, so record it rather than only ever recording processes we launched ourselves
292
+ await ensureProcessRecord({
293
+ screenName: futureScreenName,
294
+ folder: os.homedir() + "/" + SERVICE_FOLDER + canonicalScreenName + "/",
295
+ panePid: existing.pid,
296
+ record: {
297
+ serviceId: config.serviceId,
298
+ serviceKey: config.next.key,
299
+ index: config.index,
300
+ machineId: config.machineId,
301
+ parameters: config.next,
302
+ },
303
+ });
291
304
  console.log(green(`Verified future instance ${magenta(futureScreenName)} is running`));
292
305
  return;
293
306
  }
@@ -515,6 +528,22 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
515
528
  if (isPrepTime) {
516
529
  // From prep time on, the canonical (old) screen is left completely untouched — the folder holds the NEW code and parameters by now, so the normal compare/sync logic below must not run against the old process
517
530
  if (screenStateMap.get(screenName)?.isProcessRunning) {
531
+ // The old instance keeps running untouched through the overlap, but it still needs a record - it may have been launched before we restarted
532
+ let panePid = screenStateMap.get(screenName)?.pid;
533
+ if (panePid) {
534
+ await ensureProcessRecord({
535
+ screenName,
536
+ folder,
537
+ panePid,
538
+ record: {
539
+ serviceId: config.serviceId,
540
+ serviceKey: config.parameters.key,
541
+ index: i,
542
+ machineId,
543
+ parameters: instanceParameters,
544
+ },
545
+ });
546
+ }
518
547
  await syncTimeline(screenStateMap.get(screenName)?.pid);
519
548
  let nodePathId = folder + SERVICE_NODE_FILE_NAME;
520
549
  if (await fsExistsAsync(nodePathId)) {
@@ -573,6 +602,22 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
573
602
  if (prevParameters !== newParametersString) {
574
603
  await fs.promises.writeFile(parameterPath, newParametersString);
575
604
  }
605
+ // We did not launch this process (we restarted, or it predates process records), so make sure it is recorded before we leave it alone
606
+ let panePid = screenStateMap.get(screenName)?.pid;
607
+ if (panePid) {
608
+ await ensureProcessRecord({
609
+ screenName,
610
+ folder,
611
+ panePid,
612
+ record: {
613
+ serviceId: config.serviceId,
614
+ serviceKey: config.parameters.key,
615
+ index: i,
616
+ machineId,
617
+ parameters: instanceParameters,
618
+ },
619
+ });
620
+ }
576
621
  await syncTimeline(screenStateMap.get(screenName)?.pid);
577
622
  console.log(green(`Verified ${magenta(screenName)} is running`));
578
623
  continue;
@@ -11,7 +11,7 @@ import { forceRemoveNode } from "../-f-node-discovery/NodeDiscovery";
11
11
  import { fsExistsAsync } from "../fs";
12
12
  import { PromiseObj } from "../promise";
13
13
  import { SERVICE_FOLDER, SERVICE_NODE_FILE_NAME } from "./machineSchema";
14
- import { ProcessRecord, createLaunchId, getLogPipeScript, getLogTailScript, getProcessLogPath, getPipeScriptPath, getTailScriptPath, writeProcessRecord } from "./processLogs";
14
+ import { ProcessRecord, createLaunchId, getLogPipeScript, getLogTailScript, getProcessLogPath, getPipeScriptPath, getTailScriptPath, listProcessRecords, writeProcessRecord } from "./processLogs";
15
15
 
16
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
17
 
@@ -244,6 +244,38 @@ ${config.command}
244
244
  return launchId;
245
245
  });
246
246
 
247
+ /** Links an already-running screen to a process record, creating one when this process has never been recorded - it was launched before we restarted, or before process records existed at all. Idempotent: a process we already know about is left alone, so the pipe is only attached the first time we adopt it.
248
+ *
249
+ * The screen identifies itself (pane pid + the start time the OS reports for it), so adoption is just naming what is already there - there is nothing to reconcile and nothing to guess. */
250
+ export async function ensureProcessRecord(config: {
251
+ screenName: string;
252
+ folder: string;
253
+ panePid: string;
254
+ record: Omit<ProcessRecord, "launchId" | "folder" | "screenName" | "startTime" | "pid">;
255
+ }): Promise<void> {
256
+ let startTime = await getProcessStartTime(config.panePid);
257
+ if (!startTime) return;
258
+ let launchId = createLaunchId(config.panePid, startTime);
259
+ let existing = (await listProcessRecords()).find(x => x.launchId === launchId && x.folder === config.folder);
260
+ if (existing) {
261
+ // A takeover renames the session, so the record follows the name it now runs under
262
+ if (existing.screenName === config.screenName) return;
263
+ await writeProcessRecord({ ...existing, screenName: config.screenName });
264
+ return;
265
+ }
266
+ console.log(`Adopting already-running process ${launchId} on screen ${config.screenName}, which has no record yet`);
267
+ await writeProcessRecord({
268
+ ...config.record,
269
+ launchId,
270
+ folder: config.folder,
271
+ screenName: config.screenName,
272
+ pid: parseInt(config.panePid) || undefined,
273
+ startTime,
274
+ });
275
+ // Its output was going to whatever the previous incarnation pointed at (or nowhere), so point it at its own log from here on
276
+ await setupPipePane({ screenName: config.screenName, folder: config.folder, launchId });
277
+ }
278
+
247
279
  // Points the screen's pipe-pane at this launch's own log file.
248
280
  async function setupPipePane(config: { screenName: string; folder: string; launchId: string }) {
249
281
  let prefix = getTmuxPrefix();
@@ -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";
@@ -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
+ }