querysub 0.575.0 → 0.577.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 +2 -2
- package/src/-a-archives/archives2.ts +17 -1
- package/src/3-path-functions/PathFunctionRunner.ts +8 -6
- package/src/3-path-functions/pathFunctionLoader.ts +4 -3
- package/src/3-path-functions/syncSchema.ts +4 -2
- package/src/deployManager/components/MachineDetailPage.tsx +1 -1
- package/src/deployManager/components/ServiceDetailPage.tsx +1 -1
- package/src/deployManager/components/ServicesListPage.tsx +24 -1
- package/src/deployManager/components/StoragePage.tsx +1 -1
- package/src/deployManager/components/deployButtons.tsx +39 -21
- package/src/deployManager/machineApplyMainCode.ts +16 -6
- package/src/deployManager/machineSchema.ts +3 -5
- package/src/deployManager/processLogs.ts +10 -0
- package/src/deployManager/processManager.ts +18 -7
- package/src/deployManager/serviceCategories.ts +28 -0
- package/src/deployManager/setupMachineMain.ts +157 -24
- package/src/diagnostics/managementPages.tsx +4 -0
- package/src/diagnostics/misc-pages/RoutingTablePage.tsx +1 -3
- package/src/misc/nodeCategoryColors.ts +5 -0
- package/src/deployManager/machine.sh +0 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "querysub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.577.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.
|
|
74
|
+
"sliftutils": "^1.7.48",
|
|
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:
|
|
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
|
|
352
|
+
specFilePath = "." + specFilePath;
|
|
352
353
|
}
|
|
353
|
-
path = packagePath
|
|
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(
|
|
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
|
-
|
|
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
|
|
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?.
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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].
|
|
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 && !
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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. */
|
|
@@ -104,6 +104,16 @@ export async function findProcessRecordByPid(folder: string, pid: string): Promi
|
|
|
104
104
|
return (await readFolderRecords(folder)).find(x => x.pid === pid && x.deadTime === undefined);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/** Marks a process dead now, for a process we killed ourselves. Without this the record reads as running until the next resync happens to look, which is a long time to show something that we know is gone. */
|
|
108
|
+
export async function markProcessDead(pid: string, now: number): Promise<void> {
|
|
109
|
+
for (let record of await listProcessRecords()) {
|
|
110
|
+
if (record.pid !== pid || record.deadTime !== undefined) continue;
|
|
111
|
+
record.deadTime = now;
|
|
112
|
+
console.log(`Process ${record.pid} (${record.screenName}) was killed, marking it dead. Its log is deleted at ${new Date(record.deadTime + DEAD_PROCESS_RETENTION).toISOString()}`);
|
|
113
|
+
await writeProcessRecord(record);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
107
117
|
export async function writeProcessRecord(record: ProcessRecord): Promise<void> {
|
|
108
118
|
await fs.promises.mkdir(path.join(record.folder, PROCESS_FOLDER), { recursive: true });
|
|
109
119
|
await fs.promises.writeFile(getRecordPath(record.folder, record.pid, record.startTime), JSON.stringify(record));
|
|
@@ -13,7 +13,7 @@ import { forceRemoveNode } from "../-f-node-discovery/NodeDiscovery";
|
|
|
13
13
|
import { fsExistsAsync } from "../fs";
|
|
14
14
|
import { PromiseObj } from "../promise";
|
|
15
15
|
import { SERVICE_FOLDER, SERVICE_NODE_FILE_NAME } from "./machineSchema";
|
|
16
|
-
import { ProcessRecord, getLogPipeScript, getLogTailScript, getProcessLogPath, getPipeScriptPath, getTailScriptPath, findProcessRecordByPid, writeProcessRecord } from "./processLogs";
|
|
16
|
+
import { ProcessRecord, getLogPipeScript, getLogTailScript, getProcessLogPath, getPipeScriptPath, getTailScriptPath, findProcessRecordByPid, writeProcessRecord, markProcessDead } from "./processLogs";
|
|
17
17
|
import { setPreciseTimeout } from "../misc";
|
|
18
18
|
|
|
19
19
|
// 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.
|
|
@@ -256,9 +256,11 @@ export const runScreenCommand = measureWrap(async function runScreenCommand(conf
|
|
|
256
256
|
let screenName = config.screenName;
|
|
257
257
|
|
|
258
258
|
// Always a brand new session, so the pane is always new - and the pane's pid is what makes this a distinct process with its own log. A session already under this name is debris from a start that never got taken over.
|
|
259
|
-
|
|
259
|
+
let leftoverPid = await getScreenPanePid(screenName);
|
|
260
|
+
if (leftoverPid) {
|
|
260
261
|
console.log(red(`Removing the leftover session ${screenName} from a start that never completed`));
|
|
261
262
|
await runPromise(`${prefix}tmux kill-session -t ${screenName}`);
|
|
263
|
+
await markProcessDead(leftoverPid, Date.now());
|
|
262
264
|
}
|
|
263
265
|
await runPromise(`${prefix}tmux new -s ${screenName} -d`);
|
|
264
266
|
|
|
@@ -339,10 +341,12 @@ export async function retireCurrentScreen(config: {
|
|
|
339
341
|
let retiringScreenName = getRetiringScreenName(canonicalScreenName, killTime);
|
|
340
342
|
console.log(green(`Retiring ${canonicalScreenName} as ${retiringScreenName}`));
|
|
341
343
|
await runPromise(`${getTmuxPrefix()}tmux rename-session -t ${canonicalScreenName} ${retiringScreenName}`);
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
344
|
+
void (async () => {
|
|
345
|
+
let record = await findProcessRecordByPid(folder, outgoingPid);
|
|
346
|
+
if (record) {
|
|
347
|
+
await writeProcessRecord({ ...record, screenName: retiringScreenName });
|
|
348
|
+
}
|
|
349
|
+
})();
|
|
346
350
|
if (nodeId) {
|
|
347
351
|
// The file in the folder belongs to its replacement now, so only the registration goes
|
|
348
352
|
void removeServiceNode({ folder, nodeId, ownsFile: false });
|
|
@@ -353,9 +357,11 @@ export async function retireCurrentScreen(config: {
|
|
|
353
357
|
|
|
354
358
|
/** Ends a session at once, with no notice - for a screen that should not exist at all, so there is nothing to wind down gracefully. Finding one is always a surprise, so the reason it was not supposed to be there is logged. */
|
|
355
359
|
export async function killScreenNow(screenName: string, reason: string): Promise<void> {
|
|
356
|
-
|
|
360
|
+
let panePid = await getScreenPanePid(screenName);
|
|
361
|
+
if (!panePid) return;
|
|
357
362
|
console.log(red(`Unexpected screen ${screenName}: ${reason}. Killing it immediately.`));
|
|
358
363
|
await runPromise(`${getTmuxPrefix()}tmux kill-session -t ${screenName}`);
|
|
364
|
+
await markProcessDead(panePid, Date.now());
|
|
359
365
|
}
|
|
360
366
|
|
|
361
367
|
// Kills a screen and nothing else. Which node the process registered as, and whether that registration should be dropped, is the deploy logic's business - it reads the node id off the process's record, which was taken before any replacement could overwrite the file.
|
|
@@ -364,10 +370,15 @@ export const killScreen = measureWrap(async function killScreen(config: {
|
|
|
364
370
|
}) {
|
|
365
371
|
console.log(red(`Killing screen ${config.screenName} (Ctrl+C, then killing the session in ${formatTime(SHUTDOWN_GRACE_TIME)})`));
|
|
366
372
|
let prefix = getTmuxPrefix();
|
|
373
|
+
// Taken before the grace period, as the process is free to exit on the Ctrl+C and take the pane's pid with it
|
|
374
|
+
let panePid = await getScreenPanePid(config.screenName);
|
|
367
375
|
// Purely a notice to the process that it is going away, so it can wind down. We never check whether it worked and we never reuse the screen - the session is killed either way.
|
|
368
376
|
void runPromise(`${prefix}tmux send-keys -t ${config.screenName} 'C-c' Enter`);
|
|
369
377
|
await delay(SHUTDOWN_GRACE_TIME);
|
|
370
378
|
await runPromise(`${prefix}tmux kill-session -t ${config.screenName}`);
|
|
379
|
+
if (panePid) {
|
|
380
|
+
await markProcessDead(panePid, Date.now());
|
|
381
|
+
}
|
|
371
382
|
});
|
|
372
383
|
|
|
373
384
|
/** Streams one process's log: everything already in it, then everything appended after. One process, one file - nothing here has to reason about which process a byte came from. */
|
|
@@ -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.
|
|
414
|
-
|
|
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
|
-
//
|
|
417
|
-
|
|
418
|
-
await
|
|
419
|
-
|
|
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
|
-
//
|
|
423
|
-
|
|
555
|
+
// Start the machine service immediately
|
|
556
|
+
console.log("Starting machine service...");
|
|
557
|
+
await runPromise(`ssh ${sshRemote} "~/machine-startup.sh"`);
|
|
424
558
|
|
|
425
|
-
//
|
|
426
|
-
let
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
434
|
-
|
|
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
|