querysub 0.576.0 → 0.578.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.576.0",
3
+ "version": "0.578.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",
@@ -71,7 +71,7 @@
71
71
  "node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6",
72
72
  "pako": "^2.1.0",
73
73
  "peggy": "^5.0.6",
74
- "sliftutils": "^1.7.45",
74
+ "sliftutils": "^1.7.49",
75
75
  "socket-function": "^1.2.26",
76
76
  "terser": "^5.31.0",
77
77
  "typenode": "^6.6.1",
@@ -7,6 +7,7 @@ import { timeInMinute } from "socket-function/src/misc";
7
7
 
8
8
  export const STORAGE_ACCOUNT = "root";
9
9
 
10
+
10
11
  function createSourceWindows(
11
12
  overrides: Partial<RemoteConfigBase>,
12
13
  sourceWindows: {
@@ -76,7 +77,7 @@ const archivesBuilderCache = cacheJSONArgsEqual((domain: string, overrides: Part
76
77
  domain = domain.toLowerCase().replaceAll(/[^a-z0-9-]/g, "-");
77
78
  return archiveBuilder(domain, overrides);
78
79
  });
79
- function nestBucket(folder: string, archives: IArchives): IArchives {
80
+ function nestBucket(folder: string, archives: ReturnType<typeof createArchives>) {
80
81
  if (!folder) return archives;
81
82
  if (!folder.endsWith("/")) {
82
83
  folder = folder + "/";
@@ -94,6 +95,7 @@ function nestBucket(folder: string, archives: IArchives): IArchives {
94
95
  hasWriteAccess: () => archives.hasWriteAccess(),
95
96
  getConfig: () => archives.getConfig(),
96
97
  getURL: (path: string) => archives.getURL(folder + path),
98
+ getURLs: (path: string) => archives.getURLs(folder + path),
97
99
  };
98
100
  }
99
101
 
@@ -124,4 +126,18 @@ export function getArchives2PublicImmutable(folder: string) {
124
126
  export function getArchives2Public(folder: string) {
125
127
  let domain = getSafeDomain();
126
128
  return nestBucket(folder, archivesBuilderCache(domain + "-public", { public: true, allowedOrigins: getAllowedOrigins(getDomain()) }));
129
+ }
130
+
131
+
132
+ export async function initArchives2() {
133
+ let buckets = [
134
+ getArchives2(""),
135
+ getArchives2Buffered(""),
136
+ getArchives2PrivateImmutable(""),
137
+ getArchives2PublicImmutable(""),
138
+ getArchives2Public(""),
139
+ ];
140
+ for (let bucket of buckets) {
141
+ await bucket.hasWriteAccess()
142
+ }
127
143
  }
@@ -764,6 +764,14 @@ export class PathFunctionRunner {
764
764
  throw new Error(`Could not sync stable function spec, aborting call. Retries: ${retries}`);
765
765
  }
766
766
 
767
+ let devModule = getDevelopmentModule(functionSpec.ModuleId);
768
+ let devFunctionMetadata: FunctionMetadata | undefined;
769
+ if (devModule) {
770
+ let schemaObj = getSchemaObject(devModule);
771
+ devFunctionMetadata = schemaObj?.functionMetadata?.[functionSpec.FunctionId];
772
+ functionSpec = { ...functionSpec }
773
+ functionSpec.FilePath = devModule.filename;
774
+ }
767
775
  let moduleExports = await this.getExportsFromSpec(functionSpec);
768
776
 
769
777
  // ALWAYS get the permissions as well. This isn't perfect, as we could very well access data in other files, but... it helps
@@ -781,13 +789,7 @@ export class PathFunctionRunner {
781
789
  throw new Error(`Export at ${JSON.stringify(exportPath.join("."))} was not a function (it was ${typeof baseFunction}). If in development, you must restart the development function runner server when you add new functions.`);
782
790
  }
783
791
 
784
- let devFunctionMetadata: FunctionMetadata | undefined;
785
792
 
786
- let devModule = getDevelopmentModule(functionSpec.ModuleId);
787
- if (devModule) {
788
- let schemaObj = getSchemaObject(devModule);
789
- devFunctionMetadata = schemaObj?.functionMetadata?.[functionSpec.FunctionId];
790
- }
791
793
 
792
794
  console.info(`Running function: ${debugNameColored}`, {
793
795
  callId: callSpec.CallId,
@@ -24,6 +24,7 @@ import { sha256 } from "js-sha256";
24
24
  import os from "os";
25
25
  import { formatTime } from "socket-function/src/formatting/format";
26
26
  import path from "path";
27
+ import pathMod from "path";
27
28
 
28
29
  export type LoadFunctionSpec = {
29
30
  gitURL: string;
@@ -348,9 +349,9 @@ async function getModuleFromSpecBase(
348
349
 
349
350
  let specFilePath = spec.FilePath;
350
351
  if (specFilePath.startsWith("/")) {
351
- specFilePath = specFilePath.slice(1);
352
+ specFilePath = "." + specFilePath;
352
353
  }
353
- path = packagePath + specFilePath;
354
+ path = pathMod.resolve(packagePath, specFilePath);
354
355
  deployPath = packagePath + "deploy.ts";
355
356
  }
356
357
 
@@ -372,7 +373,7 @@ async function getModuleFromSpecBase(
372
373
  });
373
374
  });
374
375
  } catch (e: any) {
375
- throw new Error(`Error when loading function for ${JSON.stringify(path)}:${spec.FunctionId}\n${e.stack}`);
376
+ throw new Error(`${!isPublic() && "(IF you added a new module, restart the local function runner so it sees it!) " || ""}Error when loading function for ${JSON.stringify(path)}:${spec.FunctionId}\n${e.stack}`);
376
377
  }
377
378
 
378
379
 
@@ -5,7 +5,7 @@ import { Args } from "socket-function/src/types";
5
5
  import { appendToPathStr, getPathFromStr, getPathStr, joinPathStres, rootPathStr } from "../path";
6
6
  import { writeFunctionCall } from "./PathFunctionHelpers";
7
7
  import { CallSpec, functionSchema } from "./PathFunctionRunner";
8
- import { getDomain, isLocal } from "../config";
8
+ import { getDomain, isLocal, isPublic } from "../config";
9
9
  import { isHotReloading } from "socket-function/hot/HotReloadController";
10
10
  import { Schema2, Schema2Fncs, Schema2T, SchemaPath } from "../2-proxy/schema2";
11
11
  import { PathValueProxyWatcher, atomic, proxyWatcher, registerSchema } from "../2-proxy/PathValueProxyWatcher";
@@ -474,7 +474,9 @@ export function syncSchema<Schema>(schema?: Schema2): SyncSchemaResult<Schema> {
474
474
  // NOTE: We clobber the previous value, as we might just be hotreloading, which
475
475
  // could mean the exports value has our previous value.
476
476
  module.exports[SCHEMA_EXPORT_KEY] = result;
477
- developmentModules.set(moduleId, module);
477
+ if (!isPublic()) {
478
+ developmentModules.set(moduleId, module);
479
+ }
478
480
  return result;
479
481
  };
480
482
  }
@@ -192,7 +192,7 @@ export class MachineDetailPage extends qreact.Component {
192
192
  Launches: {serviceInfo.totalTimesLaunched}
193
193
  </div>
194
194
  <div>
195
- Guessed Node ID: {serviceInfo.nodeId}
195
+ Guessed Node IDs: {Object.entries(serviceInfo.nodeIds || {}).map(([index, nodeId]) => `#${index} ${nodeId}`).join(", ")}
196
196
  </div>
197
197
  {hasError && (
198
198
  <div className={css.colorhsl(0, 80, 50)}>
@@ -349,7 +349,7 @@ export class ServiceDetailPage extends qreact.Component {
349
349
  {screenName}
350
350
  </div>
351
351
  <div>
352
- {serviceInfo?.nodeId || machineId} ({machineInfo.info["getExternalIP"]})
352
+ {serviceInfo?.nodeIds?.[index] || machineId} ({machineInfo.info["getExternalIP"]})
353
353
  </div>
354
354
  {Object.keys(variables).length > 0 && <>
355
355
  {Object.entries(variables).map(([name, value]) =>
@@ -4,7 +4,9 @@ import { DEFAULT_OVERLAP_TIME, MachineServiceController, ServiceConfig, getLiveS
4
4
  import { css } from "typesafecss";
5
5
  import { t } from "../../2-proxy/schema2";
6
6
  import { Querysub } from "../../4-querysub/Querysub";
7
- import { currentViewParam, selectedServiceIdParam } from "../urlParams";
7
+ import { currentViewParam, selectedServiceIdParam, selectedMachineIdParam } from "../urlParams";
8
+ import { managementPageURL } from "../../diagnostics/managementPages";
9
+ import { getServiceCategory } from "../serviceCategories";
8
10
  import { formatDateTimeDetailed, formatNiceDateTime, formatTime, formatVeryNiceDateTime } from "socket-function/src/formatting/format";
9
11
  import { sort, timeInMinute, timeInSecond } from "socket-function/src/misc";
10
12
  import { cache } from "socket-function/src/caching";
@@ -18,6 +20,26 @@ import { Tools } from "./Tools";
18
20
 
19
21
  module.hotreload = true;
20
22
 
23
+ /** What kind of service this is, when we can tell from its command, linking to wherever that kind is administered. Its own box beside the service's, so clicking the service still opens the service. */
24
+ class ServiceCategoryBadge extends qreact.Component<{ config: ServiceConfig }> {
25
+ render() {
26
+ let category = getServiceCategory(getLiveServiceParameters(this.props.config));
27
+ if (!category) return undefined;
28
+ let target = category.target;
29
+ // Leaving the service and machine selections behind, as they mean nothing to the page being opened
30
+ let values = "view" in target
31
+ ? [currentViewParam.getOverride(target.view), selectedServiceIdParam.getOverride(""), selectedMachineIdParam.getOverride("")]
32
+ : [managementPageURL.getOverride(target.managementPage)];
33
+ return <Anchor noStyles
34
+ values={values}
35
+ title={`Administered from ${category.label}`}
36
+ className={css.pad2(12).button.bord2(0, 0, 20).boldStyle.hbox(0).alignItems("center").alignSelf("stretch").flexShrink0.whiteSpace("nowrap").background(category.color)}
37
+ >
38
+ {category.label}
39
+ </Anchor>;
40
+ }
41
+ }
42
+
21
43
  export class ServicesListPage extends qreact.Component {
22
44
 
23
45
  render() {
@@ -117,6 +139,7 @@ export class ServicesListPage extends qreact.Component {
117
139
  let unknown = enabledMachineIds.length - runningMachines.length - failingMachines.length - missingMachines.length;
118
140
  let duplicateKey = (keyCounts.get(config.parameters.key || "") || 0) > 1;
119
141
  return <div className={css.hbox(10)}>
142
+ <ServiceCategoryBadge config={config} />
120
143
  <Anchor noStyles key={serviceId}
121
144
  values={[currentViewParam.getOverride("service-detail"), selectedServiceIdParam.getOverride(serviceId)]}
122
145
  className={
@@ -18,10 +18,10 @@ import { STORAGE_ACCOUNT } from "../../-a-archives/archives2";
18
18
  import { Table } from "../../5-diagnostics/Table";
19
19
  import { RouteConfigView } from "./RouteConfigView";
20
20
  import { Tag, SourceTag, getSourceTags } from "./Tag";
21
+ import { STORAGE_COMMAND_PREFIX } from "../serviceCategories";
21
22
 
22
23
  module.hotreload = true;
23
24
 
24
- const STORAGE_COMMAND_PREFIX = "yarn storageserve";
25
25
  const URL_ARG_REGEX = /--url\s+(\S+)/g;
26
26
 
27
27
  /** The release a storage service is in the middle of. Read straight from the service configs, so it resolves without contacting any storage server - which is exactly when it matters most, since a deploy is the usual reason the servers don't answer. */
@@ -85,9 +85,8 @@ export class UpdateButtons extends qreact.Component<{
85
85
  let updated = deepCloneJSON(service);
86
86
  updated.parameters.gitRef = latestRef;
87
87
  updated.parameters.releaseTime = Date.now();
88
- if (noOverlap) {
89
- updated.parameters.overlapTime = 0;
90
- }
88
+ // Always set, never inherited: the overlap belongs to the deploy being made, and leaving the previous deploy's value in place means one no-overlap deploy silently makes every later deploy a no-overlap one
89
+ updated.parameters.overlapTime = noOverlap && 0 || DEFAULT_OVERLAP_TIME;
91
90
  return updated;
92
91
  });
93
92
  let controller = MachineServiceController(SocketFunction.browserNodeId());
@@ -197,34 +196,41 @@ export class UpdateServiceButtons extends qreact.Component<{
197
196
  state = t.state({
198
197
  isDeploying: t.type(false),
199
198
  });
199
+ // Deploys to latestRef, always immediately. The overlap is what keeps the service up - the old instances keep running for it, so connections have somewhere to go while clients move across. noOverlap kills them at once instead.
200
+ private deploy(latestRef: string, noOverlap: boolean) {
201
+ this.state.isDeploying = true;
202
+ // Props are synchronized state, so everything the async work needs is cloned into plain locals HERE, in the synced part
203
+ let service = deepCloneJSON(this.props.service);
204
+ service.parameters.gitRef = latestRef;
205
+ service.parameters.releaseTime = Date.now();
206
+ // Always set, never inherited - see deployAll
207
+ service.parameters.overlapTime = noOverlap && 0 || DEFAULT_OVERLAP_TIME;
208
+ let controller = MachineServiceController(SocketFunction.browserNodeId());
209
+ Querysub.onCommitFinished(async () => {
210
+ try {
211
+ await controller.setServiceConfigs.promise([service]);
212
+ } finally {
213
+ Querysub.commit(() => {
214
+ this.state.isDeploying = false;
215
+ });
216
+ }
217
+ });
218
+ }
200
219
  render() {
201
220
  let controller = MachineServiceController(SocketFunction.browserNodeId());
202
221
  const gitInfo = controller.getGitInfo();
203
222
  let gitRef = this.props.service.parameters.gitRef;
204
223
  let repoUrl = this.props.service.parameters.repoUrl;
205
224
  if (gitInfo?.repoUrl !== repoUrl) return undefined;
225
+ if (gitRef === gitInfo.latestRef) return undefined;
206
226
 
207
227
  return <>
208
- {gitRef !== gitInfo.latestRef && <button
228
+ <button
209
229
  className={buttonStyle.hsl(120, 70, 90).alignSelf("stretch")}
210
230
  disabled={this.state.isDeploying}
231
+ title={`Deploys now; the old instances keep running for ${formatTime(DEFAULT_OVERLAP_TIME)} before shutting down`}
211
232
  onClick={() => {
212
- this.state.isDeploying = true;
213
- // Props are synchronized state, so everything the async work needs is cloned into plain locals HERE, in the synced part
214
- let service = deepCloneJSON(this.props.service);
215
- service.parameters.gitRef = gitInfo.latestRef;
216
- // Delay the release by the configured overlap (an unrelated value, but a reasonable delay)
217
- let overlap = service.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME;
218
- service.parameters.releaseTime = Date.now() + overlap;
219
- Querysub.onCommitFinished(async () => {
220
- try {
221
- await controller.setServiceConfigs.promise([service]);
222
- } finally {
223
- Querysub.commit(() => {
224
- this.state.isDeploying = false;
225
- });
226
- }
227
- });
233
+ this.deploy(gitInfo.latestRef, false);
228
234
  }}
229
235
  >
230
236
  <div>
@@ -238,7 +244,19 @@ export class UpdateServiceButtons extends qreact.Component<{
238
244
  <b>Current</b>
239
245
  <RenderGitRefInfo gitRef={gitRef} />
240
246
  </div>
241
- </button>}
247
+ </button>
248
+ <button
249
+ className={buttonStyle.hsl(0, 85, 70).alignSelf("stretch")}
250
+ disabled={this.state.isDeploying}
251
+ title="Deploys now AND shuts the old instances down as soon as the new ones are up (overlap of 0)"
252
+ onClick={() => {
253
+ this.deploy(gitInfo.latestRef, true);
254
+ }}
255
+ >
256
+ <div>
257
+ {bigEmoji("⚡", -4)} <span>{this.state.isDeploying && "⏳ Deploying..." || "Update to New, No Overlap"}</span>
258
+ </div>
259
+ </button>
242
260
  </>;
243
261
  }
244
262
  }
@@ -267,8 +267,8 @@ async function ensureScreen(config: {
267
267
  let serviceId = config.config.serviceId;
268
268
  let serviceConfig = config.config;
269
269
 
270
- // A version being replaced is renamed aside and given this long before it is taken down, which is the only thing an overlap is
271
- let killTime = Date.now() + (serviceConfig.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME);
270
+ // A version being replaced is renamed aside and given until this before it is taken down, which is the only thing an overlap is. Anchored to the release, not to whenever this resync happened to run, so every machine's old process goes at the same moment however staggered the resyncs are.
271
+ let killTime = (serviceConfig.parameters.releaseTime || Date.now()) + (serviceConfig.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME);
272
272
 
273
273
  // The screen name means one thing: the version that should be running. There is no second name to reason about - anything it replaced is already renamed aside and carries its own deadline.
274
274
  let parameters: ServiceParameters | undefined = getLiveServiceParameters(serviceConfig);
@@ -284,7 +284,8 @@ async function ensureScreen(config: {
284
284
  lastLaunchedTime,
285
285
  errorFromLastRun: "",
286
286
  totalTimesLaunched: launchCount,
287
- nodeId: "",
287
+ // Every instance of this service on this machine shares the entry, so a later index must not wipe out what an earlier one already found
288
+ nodeIds: machineInfo.services[serviceId]?.nodeIds || {},
288
289
  };
289
290
 
290
291
  try {
@@ -320,11 +321,14 @@ async function ensureScreen(config: {
320
321
 
321
322
  let nodePathId = folder + SERVICE_NODE_FILE_NAME;
322
323
  if (await fsExistsAsync(nodePathId)) {
323
- machineInfo.services[serviceId].nodeId = await fs.promises.readFile(nodePathId, "utf8");
324
+ machineInfo.services[serviceId].nodeIds[index] = await fs.promises.readFile(nodePathId, "utf8");
324
325
  }
325
326
 
327
+ // Whether the version in the folder is the one that should be running. Also what tells a crash apart from a deploy further down: unchanged parameters that are not running means it died on its own.
328
+ let sameParameters = sameRestartParameters(prevParameters, parameters);
329
+
326
330
  // SMOOTH PATH: what is running is not what should be, so it is renamed aside with its own deadline and keeps serving out its overlap there. The name is free from here, which is what makes a replacement and a first start the same thing.
327
- if (isUp && panePid && !sameRestartParameters(prevParameters, parameters)) {
331
+ if (isUp && panePid && !sameParameters) {
328
332
  console.log(green(`Replacing what is running as ${magenta(screenName)}: ${newParametersString}`));
329
333
  // Recorded against the parameters it was actually started with, not the ones replacing it, so its history says what it really ran
330
334
  let outgoingParameters: ServiceParameters | undefined;
@@ -354,6 +358,11 @@ async function ensureScreen(config: {
354
358
  if (!isUp && panePid) {
355
359
  await killScreenNow(screenName, "the screen is there but nothing is running in it");
356
360
  panePid = undefined;
361
+ // The process that registered is already gone, so nothing is going to deregister it - and the replacement started below writes its own registration over the file
362
+ let deadNodeId = await readServiceNodeId(folder);
363
+ if (deadNodeId) {
364
+ void removeServiceNode({ folder, nodeId: deadNodeId, ownsFile: false });
365
+ }
357
366
  }
358
367
 
359
368
  // AFTER: EXISTS correct VERSION correct RUNNING correct - nothing to start
@@ -395,7 +404,8 @@ async function ensureScreen(config: {
395
404
  serviceKey: parameters.key,
396
405
  screenName,
397
406
  machineId,
398
- reason: prevParameters && "update" || "crashed",
407
+ // Reaching a start with the parameters unchanged means nothing replaced it - it died on its own
408
+ reason: sameParameters && "crashed" || "update",
399
409
  time: Date.now(),
400
410
  });
401
411
 
@@ -58,10 +58,8 @@ export type MachineInfo = {
58
58
  errorFromLastRun: string;
59
59
  // Only times launched for the current applyNodeId, but... still very useful.
60
60
  totalTimesLaunched: number;
61
- // Might take a while to set (15 minutes or more). It's better to look in the nodes list and find the one that seems to match (maybe looking at the startup path to find it?)
62
- // TODO: Make this work correctly when we deploy multiple instances (the node needs to know which index it is so it can write to a unique file).
63
- // - We could probably set environment variables in the screen?
64
- nodeId: string;
61
+ // Keyed by the instance's index on this machine, as one machine can run several instances of a service and they each register as their own node. Might take a while to set (15 minutes or more). It's better to look in the nodes list and find the one that seems to match (maybe looking at the startup path to find it?)
62
+ nodeIds: Record<number, string>;
65
63
  }>;
66
64
  };
67
65
 
@@ -111,7 +109,7 @@ export type ServiceConfig = {
111
109
  };
112
110
  };
113
111
 
114
- export const DEFAULT_OVERLAP_TIME = timeInMinute * 10;
112
+ export const DEFAULT_OVERLAP_TIME = timeInSecond * 60;
115
113
 
116
114
  const TEMPLATE_VARIABLE_REGEX = /\$\{([a-zA-Z0-9_-]+)\}/g;
117
115
  /** The `${name}` placeholder names in a command template, in order of first appearance. */
@@ -0,0 +1,28 @@
1
+ import { ServiceParameters } from "./machineSchema";
2
+ import { ViewType } from "./urlParams";
3
+ import { DISK_PATHVALUE_COLOR, PATHVALUE_COLOR, FUNCTION_RUNNER_COLOR } from "../misc/nodeCategoryColors";
4
+
5
+ // What a service IS, worked out from the command it runs. The commands are the only signal we have - nothing in the config says "this is a storage server" - so the prefixes live here rather than in whichever page happened to need one first.
6
+ export const STORAGE_COMMAND_PREFIX = "yarn storageserve";
7
+ const PATHVALUE_COMMAND_PREFIX = "yarn server-public";
8
+ const FUNCTION_RUNNER_COMMAND_PREFIX = "yarn function-public";
9
+
10
+ export type ServiceCategory = {
11
+ label: string;
12
+ color: string;
13
+ /** Where this kind of service is administered: a tab of the deploy manager, or a management page of its own */
14
+ target: { view: ViewType } | { managementPage: string };
15
+ };
16
+
17
+ // Ordered, and matched by prefix, so a more specific command would go above a more general one
18
+ const CATEGORIES: { prefix: string; category: ServiceCategory }[] = [
19
+ { prefix: STORAGE_COMMAND_PREFIX, category: { label: "Storage", color: DISK_PATHVALUE_COLOR, target: { view: "storage" } } },
20
+ { prefix: PATHVALUE_COMMAND_PREFIX, category: { label: "Path Values", color: PATHVALUE_COLOR, target: { managementPage: "RoutingTablePage" } } },
21
+ { prefix: FUNCTION_RUNNER_COMMAND_PREFIX, category: { label: "Functions", color: FUNCTION_RUNNER_COLOR, target: { view: "deploy" } } },
22
+ ];
23
+
24
+ /** What kind of service this is, or undefined for one we can't categorize (most services). */
25
+ export function getServiceCategory(parameters: ServiceParameters): ServiceCategory | undefined {
26
+ let command = parameters.command.trimStart();
27
+ return CATEGORIES.find(x => command.startsWith(x.prefix))?.category;
28
+ }
@@ -7,11 +7,113 @@ import os from "os";
7
7
  import readline from "readline";
8
8
  import open from "open";
9
9
  import { fsExistsAsync } from "../fs";
10
+ import { delay } from "socket-function/src/batching";
10
11
  // Import querysub, to fix missing dependencies
11
12
  Querysub;
12
13
 
13
14
  const pinnedNodeVersion = 22;
14
15
 
16
+ const SERVICE_NAME = "machine-alwaysup";
17
+ const SERVICE_UNIT_NAME = `${SERVICE_NAME}.service`;
18
+ const DAEMON_SCRIPT_NAME = "machine-daemon.sh";
19
+ // How long after the process dies before it is started again. Short, because until it is back nothing on the machine is being deployed or repaired.
20
+ const SERVICE_RESTART_DELAY_SECONDS = 5;
21
+ // The supervisor holds almost no memory, and it is the only thing that can bring the services back. The kernel should take the services it runs long before it takes it.
22
+ const SERVICE_OOM_SCORE_ADJUST = -500;
23
+
24
+ // How often the screen is checked, and how long a newly created one gets before it is checked - a fresh pane is a bare shell until the command comes up, which is indistinguishable from a screen whose process died
25
+ const SCREEN_CHECK_INTERVAL_SECONDS = 10;
26
+ const SCREEN_START_GRACE_SECONDS = 15;
27
+ // How long setup waits for the daemon to bring the screen up before treating the install as failed
28
+ const SCREEN_VERIFY_TIMEOUT = 60 * 1000;
29
+ const SCREEN_VERIFY_POLL_INTERVAL = 2000;
30
+
31
+ /** Keeps the machine process's tmux screen alive: creates it when it is gone, and re-creates it when nothing is running in it. The process stays in the screen where it can be watched, killed and changed by hand - what it can NOT be is its own supervisor, which is how the OOM killer taking the screen left the machine dead with nothing to bring it back.
32
+ *
33
+ * Written by setup rather than run out of the repo, so it exists from the moment the unit does - it cannot wait on a querysub publish, and a machine whose checkout is broken is exactly when it has to keep working. */
34
+ function getDaemonScript(home: string): string {
35
+ return `#! /bin/bash
36
+
37
+ SESSION=${SERVICE_NAME}
38
+
39
+ start_session() {
40
+ echo "$(date -Is) starting the $SESSION screen"
41
+ tmux kill-session -t "$SESSION" 2>/dev/null || true
42
+ tmux new-session -d -s "$SESSION"
43
+ sleep 3
44
+ tmux send-keys -t "$SESSION" "cd ${home}/machine-alwaysup && yarn machine-alwaysup" Enter
45
+ }
46
+
47
+ # The same test the deploy code uses on the service screens: a pane whose command exited still has its shell, so the session existing proves nothing - only a child process does.
48
+ is_running() {
49
+ local pane_pid
50
+ pane_pid=$(tmux list-panes -t "$SESSION" -F "#{pane_pid}" 2>/dev/null | head -n 1)
51
+ if [ -z "$pane_pid" ]; then
52
+ echo "$(date -Is) $SESSION does not exist"
53
+ return 1
54
+ fi
55
+ if ! pgrep -P "$pane_pid" > /dev/null; then
56
+ echo "$(date -Is) $SESSION exists, but nothing is running in it (pane pid $pane_pid)"
57
+ return 1
58
+ fi
59
+ return 0
60
+ }
61
+
62
+ while true; do
63
+ if ! is_running; then
64
+ start_session
65
+ sleep ${SCREEN_START_GRACE_SECONDS}
66
+ fi
67
+ sleep ${SCREEN_CHECK_INTERVAL_SECONDS}
68
+ done
69
+ `;
70
+ }
71
+
72
+ /** The daemon that keeps the machine process's screen alive. It exists because a tmux screen is not a supervisor: when the OOM killer took the screen, nothing underneath was left to start it again. */
73
+ function getServiceUnit(user: string, home: string): string {
74
+ return `[Unit]
75
+ Description=Querysub machine apply (always up)
76
+ After=network-online.target
77
+ Wants=network-online.target
78
+
79
+ [Service]
80
+ Type=simple
81
+ User=${user}
82
+ WorkingDirectory=${home}/machine-alwaysup
83
+ Environment=HOME=${home}
84
+ Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
85
+ ExecStart=/bin/bash ${home}/${DAEMON_SCRIPT_NAME}
86
+ Restart=always
87
+ RestartSec=${SERVICE_RESTART_DELAY_SECONDS}
88
+ # Everything real runs in tmux screens this daemon starts but does not own - the machine process's own screen, and every service screen under it - and the whole overlap design assumes those outlive a restart of the daemon. Killing the control group would take every one of them down each time, so only the daemon's own process is ever signalled.
89
+ KillMode=process
90
+ # The screens are where processes are expected to die and be restarted; one of them being OOM killed is what this daemon is FOR, and must not stop the unit
91
+ OOMPolicy=continue
92
+ OOMScoreAdjust=${SERVICE_OOM_SCORE_ADJUST}
93
+ StandardOutput=journal
94
+ StandardError=journal
95
+
96
+ [Install]
97
+ WantedBy=multi-user.target
98
+ `;
99
+ }
100
+
101
+ function getServiceSudoers(user: string): string {
102
+ // Both paths, as which one systemctl lives at differs by distro and a sudoers rule only matches the exact path
103
+ let commands = ["restart", "start", "stop"].flatMap(verb => [
104
+ `/usr/bin/systemctl ${verb} ${SERVICE_UNIT_NAME}`,
105
+ `/bin/systemctl ${verb} ${SERVICE_UNIT_NAME}`,
106
+ ]);
107
+ return `${user} ALL=(ALL) NOPASSWD: ${commands.join(", ")}\n`;
108
+ }
109
+
110
+ /** Writes a file on the remote verbatim. Sent base64 encoded, as the content is multi-line and full of the characters that a command line going through ssh, a local shell and a remote shell would each try to interpret. */
111
+ async function writeRemoteFile(sshRemote: string, remotePath: string, content: string, sudo: boolean): Promise<void> {
112
+ let encoded = Buffer.from(content, "utf8").toString("base64");
113
+ let write = sudo && `sudo tee ${remotePath} > /dev/null` || `cat > ${remotePath}`;
114
+ await runPromise(`ssh ${sshRemote} "echo ${encoded} | base64 -d | ${write}"`);
115
+ }
116
+
15
117
  function getGitHubKeyCachePath(repoUrl: string): string {
16
118
  let repoOwner = "";
17
119
  let repoName = "";
@@ -410,38 +512,69 @@ async function main() {
410
512
  await runPromise(`ssh ${sshRemote} "cd ~/machine-alwaysup && yarn install"`);
411
513
  console.log("✅ Dependencies installed");
412
514
 
413
- // 6. Create ~/machine-startup.sh which runs ~/machine-alwaysup/src/deployManager/machine.sh, and setup crontab
414
- console.log("Setting up cron job...");
515
+ // 6. Install the daemon that keeps the machine process running
516
+ let remoteUser = (await runPromise(`ssh ${sshRemote} "whoami"`)).trim();
517
+ let remoteHome = (await runPromise(`ssh ${sshRemote} "echo $HOME"`)).trim();
518
+ console.log(`Installing the ${SERVICE_UNIT_NAME} daemon (user ${remoteUser}, home ${remoteHome})...`);
519
+ await writeRemoteFile(sshRemote, `~/${DAEMON_SCRIPT_NAME}`, getDaemonScript(remoteHome), false);
520
+ await runPromise(`ssh ${sshRemote} "chmod +x ~/${DAEMON_SCRIPT_NAME}"`);
521
+ await writeRemoteFile(sshRemote, `/etc/systemd/system/${SERVICE_UNIT_NAME}`, getServiceUnit(remoteUser, remoteHome), true);
522
+
523
+ // Restarting the daemon is how a machine deploy takes effect, and that runs as the service user rather than a login shell, so it can never answer a password prompt
524
+ let sudoersPath = `/etc/sudoers.d/${SERVICE_NAME}`;
525
+ await writeRemoteFile(sshRemote, sudoersPath, getServiceSudoers(remoteUser), true);
526
+ await runPromise(`ssh ${sshRemote} "sudo chmod 0440 ${sudoersPath}"`);
527
+ // A malformed file here breaks sudo for everything, so it does not get to stay if it does not parse
528
+ await runPromise(`ssh ${sshRemote} "sudo visudo -cf ${sudoersPath} || sudo rm -f ${sudoersPath}"`);
529
+
530
+ // The daemon's journal is where "the screen was not running, starting it" ends up, and a non-root user can only read it from these groups
531
+ await runPromise(`ssh ${sshRemote} "sudo usermod -aG systemd-journal,adm ${remoteUser}"`, { nothrow: true });
532
+
533
+ await runPromise(`ssh ${sshRemote} "sudo systemctl daemon-reload"`);
534
+ await runPromise(`ssh ${sshRemote} "sudo systemctl enable ${SERVICE_UNIT_NAME}"`);
535
+ console.log("✅ Daemon installed and enabled at boot");
536
+
537
+ // 7. The daemon replaces the @reboot cron entry, which would otherwise start a second copy at every boot
538
+ let existingCron = await runPromise(`ssh ${sshRemote} "crontab -l"`, { nothrow: true });
539
+ if (existingCron.includes("machine-startup.sh")) {
540
+ let remainingCron = existingCron.split("\n").filter(line => !line.includes("machine-startup.sh")).join("\n");
541
+ await writeRemoteFile(sshRemote, "~/crontab-without-machine-startup.txt", remainingCron, false);
542
+ await runPromise(`ssh ${sshRemote} "crontab ~/crontab-without-machine-startup.txt && rm ~/crontab-without-machine-startup.txt"`);
543
+ console.log("✅ Removed the old @reboot cron entry, which the daemon replaces");
544
+ }
415
545
 
416
- // Create startup script directly on remote machine
417
- await runPromise(`ssh ${sshRemote} "echo '#!/bin/bash' > ~/machine-startup.sh"`);
418
- await runPromise(`ssh ${sshRemote} "echo 'cd ~/machine-alwaysup/node_modules/querysub/src/deployManager' >> ~/machine-startup.sh"`);
419
- await runPromise(`ssh ${sshRemote} "echo 'bash machine.sh' >> ~/machine-startup.sh"`);
546
+ // ~/machine-startup.sh keeps its meaning - "(re)start the machine service" - as that is what a machine deploy runs. It is only how it does it that changed.
547
+ // The screen deliberately survives a restart of the daemon, so restarting the unit alone would leave the old process running - this is the one place that means "take it down and bring it back on the new code", so it kills the screen itself.
548
+ await writeRemoteFile(sshRemote, "~/machine-startup.sh", `#!/bin/bash
549
+ sudo systemctl stop ${SERVICE_UNIT_NAME}
550
+ tmux kill-session -t ${SERVICE_NAME} 2>/dev/null || true
551
+ sudo systemctl start ${SERVICE_UNIT_NAME}
552
+ `, false);
420
553
  await runPromise(`ssh ${sshRemote} "chmod +x ~/machine-startup.sh"`);
421
554
 
422
- // 7. Setup crontab to run ~/machine-startup.sh on startup
423
- const cronEntry = "@reboot ~/machine-startup.sh";
555
+ // Start the machine service immediately
556
+ console.log("Starting machine service...");
557
+ await runPromise(`ssh ${sshRemote} "~/machine-startup.sh"`);
424
558
 
425
- // Get existing crontab, add our entry if not already present
426
- let existingCron = "";
427
- try {
428
- existingCron = await runPromise(`ssh ${sshRemote} "crontab -l"`, { nothrow: true });
429
- } catch {
430
- // No existing crontab is fine
559
+ // The daemon creating the screen is the entire point, so setup does not get to claim success on the unit merely having been started
560
+ let screenDeadline = Date.now() + SCREEN_VERIFY_TIMEOUT;
561
+ let screenUp = false;
562
+ while (Date.now() < screenDeadline) {
563
+ let panePid = (await runPromise(`ssh ${sshRemote} "tmux list-panes -t ${SERVICE_NAME} -F '#{pane_pid}' 2>/dev/null || true"`, { nothrow: true })).trim();
564
+ if (panePid) {
565
+ screenUp = true;
566
+ break;
567
+ }
568
+ await delay(SCREEN_VERIFY_POLL_INTERVAL);
431
569
  }
432
-
433
- if (!existingCron.includes("machine-startup.sh")) {
434
- // Add cron entry directly
435
- await runPromise(`ssh ${sshRemote} "(crontab -l 2>/dev/null || true; echo '${cronEntry}') | crontab -"`);
436
- console.log("✅ Cron job added");
437
- } else {
438
- console.log("✅ Cron job already exists");
570
+ if (!screenUp) {
571
+ let status = await runPromise(`ssh ${sshRemote} "systemctl status ${SERVICE_UNIT_NAME} --no-pager"`, { nothrow: true });
572
+ throw new Error(`The ${SERVICE_UNIT_NAME} daemon did not create the ${SERVICE_NAME} screen within ${SCREEN_VERIFY_TIMEOUT / 1000}s:\n${status}`);
439
573
  }
440
574
 
441
- // Start the machine service immediately
442
- console.log("Starting machine service...");
443
- await runPromise(`ssh ${sshRemote} "~/machine-startup.sh"`);
444
575
  console.log("✅ Machine service started!");
576
+ console.log(` Screen: ssh ${sshRemote} -t "tmux attach -t ${SERVICE_NAME}"`);
577
+ console.log(` Status: ssh ${sshRemote} "systemctl status ${SERVICE_UNIT_NAME}"`);
445
578
 
446
579
  console.log("\n🎉 Machine setup complete!");
447
580
  }
@@ -397,6 +397,10 @@ class ManagementRoot extends qreact.Component {
397
397
  managementPageURL.getOverride("MachinesPage"),
398
398
  currentViewParam.getOverride("deploy"),
399
399
  ]}>Deploy Application</ATag>
400
+ <ATag values={[
401
+ managementPageURL.getOverride("MachinesPage"),
402
+ currentViewParam.getOverride("storage"),
403
+ ]}>Storage</ATag>
400
404
  {pages.map(page =>
401
405
  <ATag values={[{ param: managementPageURL, value: page.componentName }]}>{page.title}</ATag>
402
406
  )}
@@ -21,11 +21,9 @@ import { formatTime, formatNumber } from "socket-function/src/formatting/format"
21
21
  import { LatencyGraph, LatencyGraphNode, LatencyGraphLink, LatencyGraphLabelLine } from "../../library-components/LatencyGraph";
22
22
  import { URLParam } from "../../library-components/URLParam";
23
23
  import { mainResets } from "../../library-components/urlResetGroups";
24
+ import { FUNCTION_RUNNER_COLOR, PATHVALUE_COLOR } from "../../misc/nodeCategoryColors";
24
25
 
25
26
  const ID_CHARS = 8;
26
- // The single place these are defined: purple means function runner (networks, function calls, querysub addCalls), blue means path value.
27
- const FUNCTION_RUNNER_COLOR = "hsl(280, 65%, 72%)";
28
- const PATHVALUE_COLOR = "hsl(210, 75%, 68%)";
29
27
 
30
28
  const PROBE_TIMEOUT_MS = 5000;
31
29
  const RANGE_BAR_WIDTH_PX = 360;
@@ -0,0 +1,5 @@
1
+ // The single place these are defined, so the routing table's graph and the service list's badges can never drift apart on what a colour means.
2
+ export const FUNCTION_RUNNER_COLOR = "hsl(280, 65%, 72%)";
3
+ export const PATHVALUE_COLOR = "hsl(210, 75%, 68%)";
4
+ /** A path value server that keeps its values on disk, rather than only serving them. */
5
+ export const DISK_PATHVALUE_COLOR = "hsl(140, 60%, 65%)";
@@ -1,9 +0,0 @@
1
- #! /bin/bash
2
- tmux new -s machine-alwaysup -d
3
- tmux send-keys -t machine-alwaysup "C-c" Enter
4
- sleep 3
5
- # ctrl+c wasn't working, which causes huge issues, so... just kill the screen so it's definitely dead
6
- tmux kill-session -t machine-alwaysup 2>/dev/null || true
7
- tmux new -s machine-alwaysup -d
8
- sleep 3
9
- tmux send-keys -t machine-alwaysup "cd ~/machine-alwaysup && yarn machine-alwaysup" Enter