sporades 0.1.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/Dockerfile.base +19 -0
- package/LICENSE +21 -0
- package/README.md +289 -0
- package/bin/sporades-host-helper.js +4001 -0
- package/bin/sporades.js +14198 -0
- package/dist/base-image.d.ts +43 -0
- package/dist/base-image.d.ts.map +1 -0
- package/dist/base-image.js +53 -0
- package/dist/base-image.js.map +1 -0
- package/dist/bundle-pipeline.d.ts +110 -0
- package/dist/bundle-pipeline.d.ts.map +1 -0
- package/dist/bundle-pipeline.js +369 -0
- package/dist/bundle-pipeline.js.map +1 -0
- package/dist/capsule-services.d.ts +94 -0
- package/dist/capsule-services.d.ts.map +1 -0
- package/dist/capsule-services.js +310 -0
- package/dist/capsule-services.js.map +1 -0
- package/dist/cli/cli-help.d.ts +16 -0
- package/dist/cli/cli-help.d.ts.map +1 -0
- package/dist/cli/cli-help.js +202 -0
- package/dist/cli/cli-help.js.map +1 -0
- package/dist/cli/cli-support.d.ts +17 -0
- package/dist/cli/cli-support.d.ts.map +1 -0
- package/dist/cli/cli-support.js +46 -0
- package/dist/cli/cli-support.js.map +1 -0
- package/dist/cli/github-autodeploy-workflow.d.ts +7 -0
- package/dist/cli/github-autodeploy-workflow.d.ts.map +1 -0
- package/dist/cli/github-autodeploy-workflow.js +185 -0
- package/dist/cli/github-autodeploy-workflow.js.map +1 -0
- package/dist/cli/host-helper-archive.d.ts +9 -0
- package/dist/cli/host-helper-archive.d.ts.map +1 -0
- package/dist/cli/host-helper-archive.js +74 -0
- package/dist/cli/host-helper-archive.js.map +1 -0
- package/dist/cli/host-helper-config.d.ts +15 -0
- package/dist/cli/host-helper-config.d.ts.map +1 -0
- package/dist/cli/host-helper-config.js +111 -0
- package/dist/cli/host-helper-config.js.map +1 -0
- package/dist/cli/host-helper-contract.d.ts +5 -0
- package/dist/cli/host-helper-contract.d.ts.map +1 -0
- package/dist/cli/host-helper-contract.js +5 -0
- package/dist/cli/host-helper-contract.js.map +1 -0
- package/dist/cli/host-helper-docker-types.d.ts +27 -0
- package/dist/cli/host-helper-docker-types.d.ts.map +1 -0
- package/dist/cli/host-helper-docker-types.js +2 -0
- package/dist/cli/host-helper-docker-types.js.map +1 -0
- package/dist/cli/host-helper-json.d.ts +6 -0
- package/dist/cli/host-helper-json.d.ts.map +1 -0
- package/dist/cli/host-helper-json.js +2 -0
- package/dist/cli/host-helper-json.js.map +1 -0
- package/dist/cli/host-helper-release-files.d.ts +4 -0
- package/dist/cli/host-helper-release-files.d.ts.map +1 -0
- package/dist/cli/host-helper-release-files.js +21 -0
- package/dist/cli/host-helper-release-files.js.map +1 -0
- package/dist/cli/host-helper-requests.d.ts +163 -0
- package/dist/cli/host-helper-requests.d.ts.map +1 -0
- package/dist/cli/host-helper-requests.js +2 -0
- package/dist/cli/host-helper-requests.js.map +1 -0
- package/dist/cli/host-helper-validation.d.ts +27 -0
- package/dist/cli/host-helper-validation.d.ts.map +1 -0
- package/dist/cli/host-helper-validation.js +256 -0
- package/dist/cli/host-helper-validation.js.map +1 -0
- package/dist/cli/host-request-builders.d.ts +143 -0
- package/dist/cli/host-request-builders.d.ts.map +1 -0
- package/dist/cli/host-request-builders.js +266 -0
- package/dist/cli/host-request-builders.js.map +1 -0
- package/dist/cli/hosted-capsule-contract.d.ts +128 -0
- package/dist/cli/hosted-capsule-contract.d.ts.map +1 -0
- package/dist/cli/hosted-capsule-contract.js +2 -0
- package/dist/cli/hosted-capsule-contract.js.map +1 -0
- package/dist/cli/sporades-host-helper.d.ts +3 -0
- package/dist/cli/sporades-host-helper.d.ts.map +1 -0
- package/dist/cli/sporades-host-helper.js +3120 -0
- package/dist/cli/sporades-host-helper.js.map +1 -0
- package/dist/cli/sporades.d.ts +3 -0
- package/dist/cli/sporades.d.ts.map +1 -0
- package/dist/cli/sporades.js +4066 -0
- package/dist/cli/sporades.js.map +1 -0
- package/dist/client.d.ts +7 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +9 -0
- package/dist/client.js.map +1 -0
- package/dist/runtime-restart-policy.d.ts +29 -0
- package/dist/runtime-restart-policy.d.ts.map +1 -0
- package/dist/runtime-restart-policy.js +53 -0
- package/dist/runtime-restart-policy.js.map +1 -0
- package/dist/sealed-server-env.d.ts +56 -0
- package/dist/sealed-server-env.d.ts.map +1 -0
- package/dist/sealed-server-env.js +170 -0
- package/dist/sealed-server-env.js.map +1 -0
- package/dist/server-runtime-source.d.ts +1262 -0
- package/dist/server-runtime-source.d.ts.map +1 -0
- package/dist/server-runtime-source.js +6397 -0
- package/dist/server-runtime-source.js.map +1 -0
- package/dist/server.d.ts +58 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +183 -0
- package/dist/server.js.map +1 -0
- package/dist/templates/client-runtime-template.d.ts +2 -0
- package/dist/templates/client-runtime-template.d.ts.map +1 -0
- package/dist/templates/client-runtime-template.js +438 -0
- package/dist/templates/client-runtime-template.js.map +1 -0
- package/dist/templates/scaffold-template.d.ts +19 -0
- package/dist/templates/scaffold-template.d.ts.map +1 -0
- package/dist/templates/scaffold-template.js +1201 -0
- package/dist/templates/scaffold-template.js.map +1 -0
- package/dist/templates/server-bundle-template.d.ts +8 -0
- package/dist/templates/server-bundle-template.d.ts.map +1 -0
- package/dist/templates/server-bundle-template.js +162 -0
- package/dist/templates/server-bundle-template.js.map +1 -0
- package/package.json +44 -0
- package/src/types/client.d.ts +161 -0
- package/src/types/server.d.ts +300 -0
|
@@ -0,0 +1,4001 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Generated by `npm run build` from TypeScript sources. Do not edit by hand.
|
|
3
|
+
|
|
4
|
+
// src/cli/sporades-host-helper.ts
|
|
5
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
6
|
+
import { constants as fsConstants, statSync } from "node:fs";
|
|
7
|
+
import { access, chmod, chown, lstat, mkdir, readdir as readdir2, readFile as readFile2, readlink, rename, rm as rm2, statfs, symlink, writeFile } from "node:fs/promises";
|
|
8
|
+
import { createHash, generateKeyPairSync, randomBytes } from "node:crypto";
|
|
9
|
+
import { freemem, loadavg, totalmem } from "node:os";
|
|
10
|
+
import path4 from "node:path";
|
|
11
|
+
|
|
12
|
+
// src/base-image.ts
|
|
13
|
+
var SPORADES_BASE_IMAGE = {
|
|
14
|
+
name: "sporades-base",
|
|
15
|
+
image: "ghcr.io/sporades/sporades-base:0.1.0-node22-alpine",
|
|
16
|
+
version: "0.1.0-node22-alpine",
|
|
17
|
+
runtimeUser: "sporades",
|
|
18
|
+
runtimeUid: 10001,
|
|
19
|
+
runtimeGid: 10001,
|
|
20
|
+
updatePolicy: {
|
|
21
|
+
defaultMode: "host-managed",
|
|
22
|
+
modes: ["host-managed", "auto-patch", "manual"],
|
|
23
|
+
autoPatchSupported: false,
|
|
24
|
+
autoPatchUnsupportedReason: "Base image updates are applied by replacing containers, not mutating them in place."
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
function baseImageRuntimeUser() {
|
|
28
|
+
return `${SPORADES_BASE_IMAGE.runtimeUid}:${SPORADES_BASE_IMAGE.runtimeGid}`;
|
|
29
|
+
}
|
|
30
|
+
function normaliseBaseImageUpdatePolicy(value) {
|
|
31
|
+
const mode = typeof value === "string" ? value : typeof value?.mode === "string" ? value.mode : SPORADES_BASE_IMAGE.updatePolicy.defaultMode;
|
|
32
|
+
if (!SPORADES_BASE_IMAGE.updatePolicy.modes.includes(mode)) {
|
|
33
|
+
return SPORADES_BASE_IMAGE.updatePolicy.defaultMode;
|
|
34
|
+
}
|
|
35
|
+
return mode;
|
|
36
|
+
}
|
|
37
|
+
function baseImageUpdatePolicy(mode = SPORADES_BASE_IMAGE.updatePolicy.defaultMode) {
|
|
38
|
+
return {
|
|
39
|
+
mode: normaliseBaseImageUpdatePolicy(mode),
|
|
40
|
+
autoPatch: {
|
|
41
|
+
supported: SPORADES_BASE_IMAGE.updatePolicy.autoPatchSupported,
|
|
42
|
+
reason: SPORADES_BASE_IMAGE.updatePolicy.autoPatchUnsupportedReason
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function baseImageMetadata(updatePolicyMode = SPORADES_BASE_IMAGE.updatePolicy.defaultMode) {
|
|
47
|
+
return {
|
|
48
|
+
name: SPORADES_BASE_IMAGE.name,
|
|
49
|
+
image: SPORADES_BASE_IMAGE.image,
|
|
50
|
+
version: SPORADES_BASE_IMAGE.version,
|
|
51
|
+
updatePolicy: baseImageUpdatePolicy(updatePolicyMode)
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function baseImageLabels(updatePolicyMode = SPORADES_BASE_IMAGE.updatePolicy.defaultMode) {
|
|
55
|
+
return {
|
|
56
|
+
"com.sporades.base-image.name": SPORADES_BASE_IMAGE.name,
|
|
57
|
+
"com.sporades.base-image.version": SPORADES_BASE_IMAGE.version,
|
|
58
|
+
"com.sporades.base-image.update-policy": normaliseBaseImageUpdatePolicy(updatePolicyMode)
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/runtime-restart-policy.ts
|
|
63
|
+
var FATAL_RUNTIME_RESTART_POLICY = {
|
|
64
|
+
dev: {
|
|
65
|
+
mode: "automatic",
|
|
66
|
+
maxAttempts: 10,
|
|
67
|
+
backoffMs: 250,
|
|
68
|
+
restartFatalEvents: ["unhandledRejection", "uncaughtException", "initHookFailed", "shutdownHookFailed"],
|
|
69
|
+
exitFatalEvents: ["sigterm", "sigint"]
|
|
70
|
+
},
|
|
71
|
+
container: {
|
|
72
|
+
mode: "bounded",
|
|
73
|
+
maxAttempts: 3,
|
|
74
|
+
backoffMs: 1e3,
|
|
75
|
+
dockerRestart: "on-failure:3",
|
|
76
|
+
restartFatalEvents: ["unhandledRejection", "uncaughtException", "initHookFailed"],
|
|
77
|
+
exitFatalEvents: ["sigterm", "sigint", "shutdownHookFailed"]
|
|
78
|
+
},
|
|
79
|
+
hosted: {
|
|
80
|
+
mode: "bounded",
|
|
81
|
+
maxAttempts: 3,
|
|
82
|
+
backoffMs: 1e3,
|
|
83
|
+
dockerRestart: "on-failure:3",
|
|
84
|
+
restartFatalEvents: ["unhandledRejection", "uncaughtException", "initHookFailed"],
|
|
85
|
+
exitFatalEvents: ["sigterm", "sigint", "shutdownHookFailed"],
|
|
86
|
+
exhaustedRouteTarget: "hosted-capsule-unavailable",
|
|
87
|
+
verificationFallbackOnly: true
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
function restartPolicyForMode(mode) {
|
|
91
|
+
const policy = FATAL_RUNTIME_RESTART_POLICY[mode];
|
|
92
|
+
if (!policy) {
|
|
93
|
+
throw new Error(`Unknown Sporades restart policy mode: ${mode}`);
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
...policy,
|
|
97
|
+
restartFatalEvents: [...policy.restartFatalEvents],
|
|
98
|
+
exitFatalEvents: [...policy.exitFatalEvents]
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function restartPolicyStatus(mode, overrides = {}) {
|
|
102
|
+
const policy = restartPolicyForMode(mode);
|
|
103
|
+
return {
|
|
104
|
+
mode: policy.mode,
|
|
105
|
+
maxAttempts: policy.maxAttempts,
|
|
106
|
+
backoffMs: policy.backoffMs,
|
|
107
|
+
dockerRestart: policy.dockerRestart ?? null,
|
|
108
|
+
restartFatalEvents: policy.restartFatalEvents,
|
|
109
|
+
exitFatalEvents: policy.exitFatalEvents,
|
|
110
|
+
...policy.exhaustedRouteTarget ? { exhaustedRouteTarget: policy.exhaustedRouteTarget } : {},
|
|
111
|
+
...policy.verificationFallbackOnly ? { verificationFallbackOnly: true } : {},
|
|
112
|
+
...overrides
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/cli/cli-support.ts
|
|
117
|
+
function errorDetails(error) {
|
|
118
|
+
if (error === null || error === void 0) {
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
return typeof error === "object" ? error : { message: String(error) };
|
|
122
|
+
}
|
|
123
|
+
function helperError(message, hint, diagnostics = null) {
|
|
124
|
+
const error = new Error(message);
|
|
125
|
+
error.hint = hint;
|
|
126
|
+
if (diagnostics) {
|
|
127
|
+
error.diagnostics = diagnostics;
|
|
128
|
+
}
|
|
129
|
+
return error;
|
|
130
|
+
}
|
|
131
|
+
function readStdin() {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
let stdin = "";
|
|
134
|
+
process.stdin.setEncoding("utf8");
|
|
135
|
+
process.stdin.on("data", (chunk) => {
|
|
136
|
+
stdin += chunk;
|
|
137
|
+
});
|
|
138
|
+
process.stdin.on("end", () => resolve(stdin));
|
|
139
|
+
process.stdin.on("error", reject);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function delay(ms) {
|
|
143
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
144
|
+
}
|
|
145
|
+
function writeResult(result, failed = false) {
|
|
146
|
+
process.stdout.write(`${JSON.stringify(result)}
|
|
147
|
+
`);
|
|
148
|
+
if (failed) {
|
|
149
|
+
process.exitCode = 1;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function writeEnvelope(result, failed = false) {
|
|
153
|
+
writeResult(result, failed);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/cli/host-helper-archive.ts
|
|
157
|
+
import { spawnSync } from "node:child_process";
|
|
158
|
+
import { readdir, rm } from "node:fs/promises";
|
|
159
|
+
import path from "node:path";
|
|
160
|
+
|
|
161
|
+
// src/cli/host-helper-release-files.ts
|
|
162
|
+
function expectedReleaseFiles(release) {
|
|
163
|
+
const files = ["server.mjs", "client.js", "index.html", "sporades.json"];
|
|
164
|
+
if (release.serverEnvIncluded) {
|
|
165
|
+
files.push(".env.sporades.server");
|
|
166
|
+
}
|
|
167
|
+
if (release.sealedServerEnvIncluded) {
|
|
168
|
+
files.push(".sporades/sealed-server-env/server-env.sealed.json");
|
|
169
|
+
}
|
|
170
|
+
return files;
|
|
171
|
+
}
|
|
172
|
+
function isExpectedClaimedReleaseFile(file) {
|
|
173
|
+
return [
|
|
174
|
+
"server.mjs",
|
|
175
|
+
"client.js",
|
|
176
|
+
"index.html",
|
|
177
|
+
"sporades.json",
|
|
178
|
+
".env.sporades.server",
|
|
179
|
+
".sporades/sealed-server-env/server-env.sealed.json"
|
|
180
|
+
].includes(file);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// src/cli/host-helper-archive.ts
|
|
184
|
+
function validateReleaseArchive(request) {
|
|
185
|
+
const release = request.release;
|
|
186
|
+
const entries = listArchiveEntries(release.remoteArchive);
|
|
187
|
+
const expectedFiles = expectedReleaseFiles(release);
|
|
188
|
+
const allNames = entries.map((entry) => normaliseArchiveEntryName(entry.name));
|
|
189
|
+
const runtimeEntries = entries.filter((entry) => !isDiscardableArchiveMetadata(entry.name));
|
|
190
|
+
const actualNames = runtimeEntries.map((entry) => normaliseArchiveEntryName(entry.name));
|
|
191
|
+
if (entries.some((entry) => !isSafeArchiveEntryType(entry))) {
|
|
192
|
+
throw helperError(
|
|
193
|
+
"Hosted Capsule release archive contains unsafe entries.",
|
|
194
|
+
"Push again so Sporades can package regular runtime files only."
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (allNames.some((name) => !isSafeArchiveEntryName(name))) {
|
|
198
|
+
throw helperError(
|
|
199
|
+
"Hosted Capsule release archive contains unsafe paths.",
|
|
200
|
+
"Push again so Sporades can package runtime files without absolute or parent-relative paths."
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
const actual = [...actualNames].sort();
|
|
204
|
+
const expected = [...expectedFiles].sort();
|
|
205
|
+
if (actual.length !== expected.length || actual.some((name, index) => name !== expected[index])) {
|
|
206
|
+
throw helperError(
|
|
207
|
+
"Hosted Capsule release archive contains unexpected files.",
|
|
208
|
+
"Push again so Sporades can package only runtime files."
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async function removeDiscardedArchiveMetadata(directory) {
|
|
213
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
214
|
+
const entryPath = path.join(directory, entry.name);
|
|
215
|
+
if (entry.name === "__MACOSX" || entry.name.startsWith("._")) {
|
|
216
|
+
await rm(entryPath, { recursive: entry.isDirectory(), force: true });
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (entry.isDirectory()) {
|
|
220
|
+
await removeDiscardedArchiveMetadata(entryPath);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function listArchiveEntries(archivePath) {
|
|
225
|
+
const namesResult = spawnSync("tar", ["-tzf", archivePath], { encoding: "utf8" });
|
|
226
|
+
const verboseResult = spawnSync("tar", ["-tvzf", archivePath], { encoding: "utf8" });
|
|
227
|
+
if (namesResult.error || namesResult.status !== 0 || verboseResult.error || verboseResult.status !== 0) {
|
|
228
|
+
throw helperError(
|
|
229
|
+
"Failed to inspect Hosted Capsule release archive.",
|
|
230
|
+
"Upload the release again with `sporades host push` and check that tar is installed on the Host server."
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
const names = namesResult.stdout.trim().split("\n").filter(Boolean);
|
|
234
|
+
const verboseLines = verboseResult.stdout.trim().split("\n").filter(Boolean);
|
|
235
|
+
if (names.length !== verboseLines.length) {
|
|
236
|
+
throw helperError(
|
|
237
|
+
"Hosted Capsule release archive could not be validated.",
|
|
238
|
+
"Push again so Sporades can package a clean runtime archive."
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return names.map((name, index) => ({
|
|
242
|
+
name,
|
|
243
|
+
type: verboseLines[index]?.[0]
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
function normaliseArchiveEntryName(name) {
|
|
247
|
+
return String(name).replace(/^\.\//, "").replace(/\/+$/, "");
|
|
248
|
+
}
|
|
249
|
+
function isDiscardableArchiveMetadata(name) {
|
|
250
|
+
const normalisedName = normaliseArchiveEntryName(name);
|
|
251
|
+
return normalisedName === "__MACOSX" || normalisedName.startsWith("__MACOSX/") || normalisedName.split("/").some((segment) => segment.startsWith("._"));
|
|
252
|
+
}
|
|
253
|
+
function isSafeArchiveEntryType(entry) {
|
|
254
|
+
if (entry.type === "-") {
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
return entry.type === "d" && isDiscardableArchiveMetadata(entry.name);
|
|
258
|
+
}
|
|
259
|
+
function isSafeArchiveEntryName(name) {
|
|
260
|
+
if (!name || name.startsWith("/") || name.includes("\0")) {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
return name.split("/").every((segment) => segment && segment !== "." && segment !== "..");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// src/cli/host-helper-config.ts
|
|
267
|
+
import { readFile } from "node:fs/promises";
|
|
268
|
+
import path2 from "node:path";
|
|
269
|
+
var HOST_HELPER_CONFIG_FILE = "sporades-host-helper.json";
|
|
270
|
+
var DEFAULT_HOSTED_CAPSULE_DOCKER_IMAGE = SPORADES_BASE_IMAGE.image;
|
|
271
|
+
var DEFAULT_HOSTED_CAPSULE_DOCKER_NETWORK = "sporades-hosted-capsules";
|
|
272
|
+
var DEFAULT_HOSTED_CAPSULE_GRACE_CHECK_MS = 500;
|
|
273
|
+
var DEFAULT_HOST_LOG_LINES = 200;
|
|
274
|
+
var DEFAULT_MAX_HOST_LOG_LINES = 1e4;
|
|
275
|
+
function defaultHostHelperConfig() {
|
|
276
|
+
return {
|
|
277
|
+
hostedCapsule: {
|
|
278
|
+
dockerImage: DEFAULT_HOSTED_CAPSULE_DOCKER_IMAGE,
|
|
279
|
+
dockerNetwork: DEFAULT_HOSTED_CAPSULE_DOCKER_NETWORK,
|
|
280
|
+
graceCheckMs: DEFAULT_HOSTED_CAPSULE_GRACE_CHECK_MS
|
|
281
|
+
},
|
|
282
|
+
logs: {
|
|
283
|
+
defaultLines: DEFAULT_HOST_LOG_LINES,
|
|
284
|
+
maxLines: DEFAULT_MAX_HOST_LOG_LINES
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
async function loadHostHelperConfig(request) {
|
|
289
|
+
const loaded = defaultHostHelperConfig();
|
|
290
|
+
const configPath = hostHelperConfigPath(request);
|
|
291
|
+
if (!configPath) {
|
|
292
|
+
return loaded;
|
|
293
|
+
}
|
|
294
|
+
let contents;
|
|
295
|
+
try {
|
|
296
|
+
contents = await readFile(configPath, "utf8");
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (errorDetails(error).code === "ENOENT" && !process.env.SPORADES_HOST_HELPER_CONFIG) {
|
|
299
|
+
return loaded;
|
|
300
|
+
}
|
|
301
|
+
throw helperError(
|
|
302
|
+
"Failed to read Host helper config.",
|
|
303
|
+
`Check that ${configPath} exists and is readable by the Host helper.`
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
let config;
|
|
307
|
+
try {
|
|
308
|
+
config = JSON.parse(contents);
|
|
309
|
+
} catch {
|
|
310
|
+
throw helperError(
|
|
311
|
+
"Host helper config is invalid JSON.",
|
|
312
|
+
`Fix ${configPath}, then retry the Host helper command.`
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
return applyHostHelperConfig(loaded, config, configPath);
|
|
316
|
+
}
|
|
317
|
+
function hostHelperConfigPath(request) {
|
|
318
|
+
if (process.env.SPORADES_HOST_HELPER_CONFIG) {
|
|
319
|
+
return process.env.SPORADES_HOST_HELPER_CONFIG;
|
|
320
|
+
}
|
|
321
|
+
if (typeof request.host?.remoteRoot !== "string" || request.host.remoteRoot.length === 0) {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
return path2.join(request.host.remoteRoot, HOST_HELPER_CONFIG_FILE);
|
|
325
|
+
}
|
|
326
|
+
function applyHostHelperConfig(loaded, config, configPath) {
|
|
327
|
+
assertPlainObject(config, "Host helper config", configPath);
|
|
328
|
+
assertKnownKeys(config, ["hostedCapsule", "logs"], "Host helper config", configPath);
|
|
329
|
+
const hostedCapsule = config.hostedCapsule ?? {};
|
|
330
|
+
assertPlainObject(hostedCapsule, "Host helper hostedCapsule config", configPath);
|
|
331
|
+
assertKnownKeys(hostedCapsule, ["dockerImage", "dockerNetwork", "graceCheckMs"], "Host helper hostedCapsule config", configPath);
|
|
332
|
+
const logs = config.logs ?? {};
|
|
333
|
+
assertPlainObject(logs, "Host helper logs config", configPath);
|
|
334
|
+
assertKnownKeys(logs, ["defaultLines", "maxLines"], "Host helper logs config", configPath);
|
|
335
|
+
if (Object.hasOwn(hostedCapsule, "dockerImage")) {
|
|
336
|
+
loaded.hostedCapsule.dockerImage = readConfigString(hostedCapsule.dockerImage, "hostedCapsule.dockerImage", configPath);
|
|
337
|
+
}
|
|
338
|
+
if (Object.hasOwn(hostedCapsule, "dockerNetwork")) {
|
|
339
|
+
loaded.hostedCapsule.dockerNetwork = readConfigString(hostedCapsule.dockerNetwork, "hostedCapsule.dockerNetwork", configPath);
|
|
340
|
+
}
|
|
341
|
+
if (Object.hasOwn(hostedCapsule, "graceCheckMs")) {
|
|
342
|
+
loaded.hostedCapsule.graceCheckMs = readConfigPositiveInteger(hostedCapsule.graceCheckMs, "hostedCapsule.graceCheckMs", configPath);
|
|
343
|
+
}
|
|
344
|
+
if (Object.hasOwn(logs, "defaultLines")) {
|
|
345
|
+
loaded.logs.defaultLines = readConfigPositiveInteger(logs.defaultLines, "logs.defaultLines", configPath);
|
|
346
|
+
}
|
|
347
|
+
if (Object.hasOwn(logs, "maxLines")) {
|
|
348
|
+
loaded.logs.maxLines = readConfigPositiveInteger(logs.maxLines, "logs.maxLines", configPath);
|
|
349
|
+
}
|
|
350
|
+
if (loaded.logs.defaultLines > loaded.logs.maxLines) {
|
|
351
|
+
throw helperError(
|
|
352
|
+
"Host helper config is invalid.",
|
|
353
|
+
`Set logs.defaultLines less than or equal to logs.maxLines in ${configPath}.`
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
return loaded;
|
|
357
|
+
}
|
|
358
|
+
function assertPlainObject(value, label, configPath) {
|
|
359
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
360
|
+
throw helperError(
|
|
361
|
+
"Host helper config is invalid.",
|
|
362
|
+
`${label} must be a JSON object in ${configPath}.`
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
function assertKnownKeys(value, knownKeys, label, configPath) {
|
|
367
|
+
for (const key of Object.keys(value)) {
|
|
368
|
+
if (!knownKeys.includes(key)) {
|
|
369
|
+
throw helperError(
|
|
370
|
+
"Host helper config is invalid.",
|
|
371
|
+
`${label} contains unsupported key "${key}" in ${configPath}.`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function readConfigString(value, key, configPath) {
|
|
377
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
378
|
+
throw helperError(
|
|
379
|
+
"Host helper config is invalid.",
|
|
380
|
+
`Set ${key} to a non-empty string in ${configPath}.`
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
return value;
|
|
384
|
+
}
|
|
385
|
+
function readConfigPositiveInteger(value, key, configPath) {
|
|
386
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
387
|
+
throw helperError(
|
|
388
|
+
"Host helper config is invalid.",
|
|
389
|
+
`Set ${key} to a positive whole number in ${configPath}.`
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
return value;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// src/cli/host-helper-validation.ts
|
|
396
|
+
import path3 from "node:path";
|
|
397
|
+
function missingCapsuleHint(request, purpose) {
|
|
398
|
+
if (purpose === "push") {
|
|
399
|
+
return `Run \`sporades host register ${request.capsule.subname} --host ${request.host.alias}\` before pushing a release.`;
|
|
400
|
+
}
|
|
401
|
+
if (purpose === "stats") {
|
|
402
|
+
return `Run \`sporades host register ${request.capsule.subname} --host ${request.host.alias}\` before reading stats.`;
|
|
403
|
+
}
|
|
404
|
+
if (purpose === "unregister") {
|
|
405
|
+
return `Run \`sporades host register ${request.capsule.subname} --host ${request.host.alias}\` before unregistering the Hosted Capsule.`;
|
|
406
|
+
}
|
|
407
|
+
return `Run \`sporades host register ${request.capsule.subname} --host ${request.host.alias}\` before managing the Hosted Capsule lifecycle.`;
|
|
408
|
+
}
|
|
409
|
+
function hostRegistryRetryCommand(request) {
|
|
410
|
+
return request.action === "host.stats" ? `sporades host stats --host ${request.host.alias}` : `sporades host list --host ${request.host.alias}`;
|
|
411
|
+
}
|
|
412
|
+
function validateLifecycleRequest(request) {
|
|
413
|
+
const requiredStrings = [
|
|
414
|
+
request.host?.domain,
|
|
415
|
+
request.host?.alias,
|
|
416
|
+
request.host?.remoteRoot,
|
|
417
|
+
request.capsule?.subname
|
|
418
|
+
];
|
|
419
|
+
if (requiredStrings.some((value) => typeof value !== "string" || value.length === 0)) {
|
|
420
|
+
throw helperError("Invalid Hosted Capsule lifecycle request.", "Update the Sporades CLI and retry the host lifecycle command.");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function validateSealedEnvRotationRequest(request) {
|
|
424
|
+
const requiredStrings = [
|
|
425
|
+
request.host?.domain,
|
|
426
|
+
request.host?.alias,
|
|
427
|
+
request.host?.remoteRoot,
|
|
428
|
+
request.capsule?.subname
|
|
429
|
+
];
|
|
430
|
+
if (requiredStrings.some((value) => typeof value !== "string" || value.length === 0)) {
|
|
431
|
+
throw helperError("Invalid Hosted Capsule sealed-env key rotation request.", "Update the Sporades CLI and retry `sporades host rotate-key`.");
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function validateStatsRequest(request) {
|
|
435
|
+
const requiredStrings = [
|
|
436
|
+
request.host?.domain,
|
|
437
|
+
request.host?.alias,
|
|
438
|
+
request.host?.remoteRoot,
|
|
439
|
+
request.capsule?.subname
|
|
440
|
+
];
|
|
441
|
+
if (requiredStrings.some((value) => typeof value !== "string" || value.length === 0)) {
|
|
442
|
+
throw helperError("Invalid Hosted Capsule stats request.", "Update the Sporades CLI and retry the host stats command.");
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function validateReleaseListRequest(request) {
|
|
446
|
+
const requiredStrings = [
|
|
447
|
+
request.host?.domain,
|
|
448
|
+
request.host?.alias,
|
|
449
|
+
request.host?.remoteRoot,
|
|
450
|
+
request.capsule?.subname
|
|
451
|
+
];
|
|
452
|
+
if (requiredStrings.some((value) => typeof value !== "string" || value.length === 0)) {
|
|
453
|
+
throw helperError("Invalid Hosted Capsule releases request.", "Update the Sporades CLI and retry `sporades host releases`.");
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function validateHealthRequest(request) {
|
|
457
|
+
const requiredStrings = [
|
|
458
|
+
request.host?.domain,
|
|
459
|
+
request.host?.alias,
|
|
460
|
+
request.host?.remoteRoot,
|
|
461
|
+
request.capsule?.subname
|
|
462
|
+
];
|
|
463
|
+
if (requiredStrings.some((value) => typeof value !== "string" || value.length === 0)) {
|
|
464
|
+
throw helperError("Invalid Hosted Capsule health request.", "Update the Sporades CLI and retry the host health command.");
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
function validateHostStatsRequest(request) {
|
|
468
|
+
const requiredStrings = [
|
|
469
|
+
request.host?.domain,
|
|
470
|
+
request.host?.alias,
|
|
471
|
+
request.host?.remoteRoot
|
|
472
|
+
];
|
|
473
|
+
if (requiredStrings.some((value) => typeof value !== "string" || value.length === 0)) {
|
|
474
|
+
throw helperError("Invalid Host stats request.", "Update the Sporades CLI and retry `sporades host stats`.");
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
function validateRollbackRequest(request) {
|
|
478
|
+
const requiredStrings = [
|
|
479
|
+
request.host?.domain,
|
|
480
|
+
request.host?.alias,
|
|
481
|
+
request.host?.remoteRoot,
|
|
482
|
+
request.capsule?.subname,
|
|
483
|
+
request.rollback?.releaseId
|
|
484
|
+
];
|
|
485
|
+
if (requiredStrings.some((value) => typeof value !== "string" || value.length === 0)) {
|
|
486
|
+
throw helperError("Invalid Hosted Capsule rollback request.", "Update the Sporades CLI and retry `sporades host rollback`.");
|
|
487
|
+
}
|
|
488
|
+
const releaseId = request.rollback?.releaseId;
|
|
489
|
+
if (!releaseId || !/^\d{8}T\d{6}Z-[a-f0-9]{8}$/.test(releaseId)) {
|
|
490
|
+
throw helperError(
|
|
491
|
+
"Invalid Hosted Capsule release ID.",
|
|
492
|
+
`Choose a recorded release ID from \`sporades host releases ${request.capsule.subname} --host ${request.host.alias} --json\`.`
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
function validateHostLogsRequest(request, limits) {
|
|
497
|
+
const requiredStrings = [
|
|
498
|
+
request.host?.alias,
|
|
499
|
+
request.host?.domain,
|
|
500
|
+
request.host?.remoteRoot
|
|
501
|
+
];
|
|
502
|
+
if (requiredStrings.some((value) => typeof value !== "string" || value.length === 0)) {
|
|
503
|
+
throw helperError("Invalid Host logs request.", "Update the Sporades CLI and retry `sporades host logs`.");
|
|
504
|
+
}
|
|
505
|
+
const source = request.logs?.source ?? "caddy-combined";
|
|
506
|
+
if (!["http", "caddy-combined", "stdout", "stderr"].includes(source)) {
|
|
507
|
+
throw helperError(
|
|
508
|
+
"Invalid Host log source.",
|
|
509
|
+
"Use `http`, `stdout`, or `stderr` for `sporades host logs`."
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
if ((source === "stdout" || source === "stderr") && (typeof request.capsule?.subname !== "string" || request.capsule.subname.length === 0)) {
|
|
513
|
+
throw helperError(
|
|
514
|
+
"Missing Capsule subname for container logs.",
|
|
515
|
+
"Pass `--subname <capsule-subname>` or run the command from a project with a Hosted Capsule binding."
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
const lines = request.logs?.lines ?? limits.defaultLines;
|
|
519
|
+
if (!Number.isInteger(lines) || lines < 1 || lines > limits.maxLines) {
|
|
520
|
+
throw helperError(
|
|
521
|
+
"Invalid Host log line count.",
|
|
522
|
+
`Pass \`--lines <n>\` with a whole number between 1 and ${limits.maxLines}.`
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
function validateListRequest(request) {
|
|
527
|
+
const requiredStrings = [
|
|
528
|
+
request.host?.domain,
|
|
529
|
+
request.host?.alias,
|
|
530
|
+
request.host?.remoteRoot
|
|
531
|
+
];
|
|
532
|
+
if (requiredStrings.some((value) => typeof value !== "string" || value.length === 0)) {
|
|
533
|
+
throw helperError("Invalid Hosted Capsule list request.", "Update the Sporades CLI and retry `sporades host list`.");
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
function validateListRegistryRecord(request, record, recordPath) {
|
|
537
|
+
const capsuleRecord = record;
|
|
538
|
+
const expectedSubname = path3.basename(recordPath, ".json");
|
|
539
|
+
const expectedRemoteCapsuleId = `${request.host.domain}/${typeof capsuleRecord?.subname === "string" ? capsuleRecord.subname : expectedSubname}`;
|
|
540
|
+
const valid = capsuleRecord && typeof capsuleRecord.subname === "string" && capsuleRecord.subname.length > 0 && capsuleRecord.subname === expectedSubname && capsuleRecord.domain === request.host.domain && (capsuleRecord.remoteCapsuleId ?? expectedRemoteCapsuleId) === expectedRemoteCapsuleId;
|
|
541
|
+
if (!valid) {
|
|
542
|
+
throw helperError(
|
|
543
|
+
"Hosted Capsule registry record is invalid.",
|
|
544
|
+
`Repair the Host server registry record at ${recordPath}, then retry \`${hostRegistryRetryCommand(request)}\`.`
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
function validateBootstrapRequest(request) {
|
|
549
|
+
const requiredStrings = [
|
|
550
|
+
request.host?.domain,
|
|
551
|
+
request.host?.alias,
|
|
552
|
+
request.host?.remoteRoot
|
|
553
|
+
];
|
|
554
|
+
if (requiredStrings.some((value) => typeof value !== "string" || value.length === 0)) {
|
|
555
|
+
throw helperError("Invalid Host bootstrap request.", "Update the Sporades CLI and retry `sporades host bootstrap`.");
|
|
556
|
+
}
|
|
557
|
+
const tlsMode = request.bootstrap?.tls?.mode ?? "automatic";
|
|
558
|
+
if (tlsMode !== "automatic" && tlsMode !== "cloudflare-origin") {
|
|
559
|
+
throw helperError(
|
|
560
|
+
"Invalid Host TLS mode.",
|
|
561
|
+
"Use `--tls automatic` for Caddy-managed certificates or `--tls cloudflare-origin` for preinstalled Cloudflare origin certificates."
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
function validateRegisterRequest(request) {
|
|
566
|
+
const requiredStrings = [
|
|
567
|
+
request.host?.domain,
|
|
568
|
+
request.host?.alias,
|
|
569
|
+
request.host?.remoteRoot,
|
|
570
|
+
request.capsule?.subname
|
|
571
|
+
];
|
|
572
|
+
if (requiredStrings.some((value) => typeof value !== "string" || value.length === 0)) {
|
|
573
|
+
throw helperError("Invalid Hosted Capsule registration request.", "Update the Sporades CLI and retry `sporades host register`.");
|
|
574
|
+
}
|
|
575
|
+
const registration = request.registration ?? {};
|
|
576
|
+
const mismatchedIdentity = registration.subname && registration.subname !== request.capsule.subname || registration.domain && registration.domain !== request.host.domain || registration.remoteCapsuleId && registration.remoteCapsuleId !== `${request.host.domain}/${request.capsule.subname}`;
|
|
577
|
+
if (mismatchedIdentity) {
|
|
578
|
+
throw helperError(
|
|
579
|
+
"Hosted Capsule registration request does not match the Host profile.",
|
|
580
|
+
"Rebind the local project or pass the correct Host profile and Capsule subname."
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
const tlsMode = request.registration?.bootstrap?.tls?.mode ?? request.bootstrap?.tls?.mode ?? "automatic";
|
|
584
|
+
if (tlsMode !== "automatic" && tlsMode !== "cloudflare-origin") {
|
|
585
|
+
throw helperError(
|
|
586
|
+
"Invalid Host TLS mode.",
|
|
587
|
+
"Use `--tls automatic` for Caddy-managed certificates or `--tls cloudflare-origin` for preinstalled Cloudflare origin certificates."
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
function validateUnregisterRequest(request) {
|
|
592
|
+
const requiredStrings = [
|
|
593
|
+
request.host?.domain,
|
|
594
|
+
request.host?.alias,
|
|
595
|
+
request.host?.remoteRoot,
|
|
596
|
+
request.capsule?.subname
|
|
597
|
+
];
|
|
598
|
+
if (requiredStrings.some((value) => typeof value !== "string" || value.length === 0)) {
|
|
599
|
+
throw helperError("Invalid Hosted Capsule unregister request.", "Update the Sporades CLI and retry `sporades host unregister`.");
|
|
600
|
+
}
|
|
601
|
+
const unregister = request.unregister ?? {};
|
|
602
|
+
const mismatchedIdentity = unregister.subname && unregister.subname !== request.capsule.subname || unregister.domain && unregister.domain !== request.host.domain || unregister.remoteCapsuleId && unregister.remoteCapsuleId !== `${request.host.domain}/${request.capsule.subname}`;
|
|
603
|
+
if (mismatchedIdentity) {
|
|
604
|
+
throw helperError(
|
|
605
|
+
"Hosted Capsule unregister request does not match the Host profile.",
|
|
606
|
+
"Rebind the local project or pass the correct Host profile and Capsule subname."
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function validateDeleteRequest(request) {
|
|
611
|
+
const requiredStrings = [
|
|
612
|
+
request.host?.domain,
|
|
613
|
+
request.host?.alias,
|
|
614
|
+
request.host?.remoteRoot,
|
|
615
|
+
request.capsule?.subname
|
|
616
|
+
];
|
|
617
|
+
if (requiredStrings.some((value) => typeof value !== "string" || value.length === 0)) {
|
|
618
|
+
throw helperError("Invalid Hosted Capsule delete request.", "Update the Sporades CLI and retry `sporades host delete`.");
|
|
619
|
+
}
|
|
620
|
+
const deletion = request.delete ?? {};
|
|
621
|
+
const mismatchedIdentity = deletion.subname && deletion.subname !== request.capsule.subname || deletion.domain && deletion.domain !== request.host.domain || deletion.remoteCapsuleId && deletion.remoteCapsuleId !== `${request.host.domain}/${request.capsule.subname}`;
|
|
622
|
+
if (mismatchedIdentity) {
|
|
623
|
+
throw helperError(
|
|
624
|
+
"Hosted Capsule delete request does not match the Host profile.",
|
|
625
|
+
"Rebind the local project or pass the correct Host profile and Capsule subname."
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function validateInstallRequest(request) {
|
|
630
|
+
const release = request.release;
|
|
631
|
+
const requiredStrings = [
|
|
632
|
+
request.host?.domain,
|
|
633
|
+
request.host?.alias,
|
|
634
|
+
request.host?.remoteRoot,
|
|
635
|
+
request.capsule?.subname,
|
|
636
|
+
release?.id,
|
|
637
|
+
release?.remoteArchive,
|
|
638
|
+
release?.hostedUrl,
|
|
639
|
+
release?.directories?.releases,
|
|
640
|
+
release?.directories?.release,
|
|
641
|
+
release?.directories?.data,
|
|
642
|
+
release?.currentLink
|
|
643
|
+
];
|
|
644
|
+
if (requiredStrings.some((value) => typeof value !== "string" || value.length === 0)) {
|
|
645
|
+
throw helperError("Invalid release install request.", "Update the Sporades CLI and retry `sporades host push`.");
|
|
646
|
+
}
|
|
647
|
+
if (!/^\d{8}T\d{6}Z-[a-f0-9]{8}$/.test(release.id)) {
|
|
648
|
+
throw helperError("Invalid Hosted Capsule release ID.", "Push again to generate a fresh UTC-sortable release ID.");
|
|
649
|
+
}
|
|
650
|
+
if (!Array.isArray(release.files) || release.files.some((file) => !isExpectedClaimedReleaseFile(file))) {
|
|
651
|
+
throw helperError("Invalid Hosted Capsule release file list.", "Update the Sporades CLI and retry `sporades host push`.");
|
|
652
|
+
}
|
|
653
|
+
const expectedFiles = expectedReleaseFiles(release);
|
|
654
|
+
const claimedFiles = [...release.files].sort();
|
|
655
|
+
const sortedExpectedFiles = [...expectedFiles].sort();
|
|
656
|
+
if (claimedFiles.length !== sortedExpectedFiles.length || claimedFiles.some((file, index) => file !== sortedExpectedFiles[index])) {
|
|
657
|
+
throw helperError("Invalid Hosted Capsule release file list.", "Update the Sporades CLI and retry `sporades host push`.");
|
|
658
|
+
}
|
|
659
|
+
const directories = release.directories;
|
|
660
|
+
if (!directories?.releases || !directories.release) {
|
|
661
|
+
throw helperError("Invalid Hosted Capsule release directory.", "Update the Sporades CLI and retry `sporades host push`.");
|
|
662
|
+
}
|
|
663
|
+
const expectedReleaseDirectory = path3.join(directories.releases, release.id);
|
|
664
|
+
if (path3.resolve(directories.release) !== path3.resolve(expectedReleaseDirectory)) {
|
|
665
|
+
throw helperError("Invalid Hosted Capsule release directory.", "Update the Sporades CLI and retry `sporades host push`.");
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// src/cli/sporades-host-helper.ts
|
|
670
|
+
var CAPSULE_RUNTIME_HEALTH_PATH = "/__sporades/health/runtime";
|
|
671
|
+
var RUNTIME_PROBE_HEADER = "x-sporades-host-probe";
|
|
672
|
+
var hostHelperConfig = defaultHostHelperConfig();
|
|
673
|
+
main().catch((error) => {
|
|
674
|
+
writeEnvelope(
|
|
675
|
+
{
|
|
676
|
+
ok: false,
|
|
677
|
+
data: null,
|
|
678
|
+
error: {
|
|
679
|
+
message: error.message,
|
|
680
|
+
hint: error.hint ?? "Check the Host helper request and retry the command.",
|
|
681
|
+
...error.diagnostics ? { diagnostics: error.diagnostics } : {}
|
|
682
|
+
}
|
|
683
|
+
},
|
|
684
|
+
false
|
|
685
|
+
);
|
|
686
|
+
});
|
|
687
|
+
async function main() {
|
|
688
|
+
const request = JSON.parse(await readStdin());
|
|
689
|
+
hostHelperConfig = await loadHostHelperConfig(request);
|
|
690
|
+
if (request.action === "capsule.register") {
|
|
691
|
+
await registerCapsule(request);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (request.action === "capsule.sealed-env.rotate-key") {
|
|
695
|
+
await rotateCapsuleSealedEnvKey(request);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (request.action === "capsule.unregister") {
|
|
699
|
+
await unregisterCapsule(request);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (request.action === "capsule.delete") {
|
|
703
|
+
await deleteCapsule(request);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (request.action === "capsule.release.install") {
|
|
707
|
+
await installRelease(request);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
if (request.action === "capsule.release.list") {
|
|
711
|
+
await listReleases(request);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (request.action === "capsule.release.rollback") {
|
|
715
|
+
await rollbackRelease(request);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (request.action === "capsule.start") {
|
|
719
|
+
await startCapsule(request);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
if (request.action === "capsule.stop") {
|
|
723
|
+
await stopCapsule(request);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (request.action === "capsule.restart") {
|
|
727
|
+
await restartCapsule(request);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (request.action === "capsule.stats") {
|
|
731
|
+
await statsCapsule(request);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (request.action === "capsule.health") {
|
|
735
|
+
await healthCapsule(request);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
if (request.action === "host.stats") {
|
|
739
|
+
await statsHost(request);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (request.action === "capsule.list") {
|
|
743
|
+
await listCapsules(request);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (request.action === "host.logs") {
|
|
747
|
+
await logsHost(request);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
if (request.action === "host.bootstrap") {
|
|
751
|
+
await bootstrapHost(request);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
throw helperError("Unsupported Host helper action.", "Update the Host helper or use a supported Sporades host command.");
|
|
755
|
+
}
|
|
756
|
+
async function bootstrapHost(request) {
|
|
757
|
+
validateBootstrapRequest(request);
|
|
758
|
+
const bootstrap = normaliseBootstrap(request);
|
|
759
|
+
await ensureBootstrapDirectories(bootstrap);
|
|
760
|
+
await validateBootstrapTls(request, bootstrap);
|
|
761
|
+
const network = ensureDockerNetwork(bootstrap.network);
|
|
762
|
+
const accessLog = await provisionCaddyAccessLog(request, bootstrap);
|
|
763
|
+
const caddy = await installCaddyBootstrapConfig(request, bootstrap);
|
|
764
|
+
writeEnvelope({
|
|
765
|
+
ok: true,
|
|
766
|
+
data: {
|
|
767
|
+
bootstrapped: true,
|
|
768
|
+
domain: request.host.domain,
|
|
769
|
+
remoteRoot: request.host.remoteRoot,
|
|
770
|
+
network,
|
|
771
|
+
packages: bootstrap.substrate.packages,
|
|
772
|
+
services: bootstrap.substrate.services,
|
|
773
|
+
directories: bootstrap.directories,
|
|
774
|
+
tls: bootstrap.tls,
|
|
775
|
+
caddy: { ...caddy, accessLog },
|
|
776
|
+
preservedCapsules: true
|
|
777
|
+
},
|
|
778
|
+
error: null
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
async function registerCapsule(request) {
|
|
782
|
+
validateRegisterRequest(request);
|
|
783
|
+
const registration = normaliseRegistration(request);
|
|
784
|
+
await ensureHostedDomainBootstrapped(request, registration);
|
|
785
|
+
let reactivated = false;
|
|
786
|
+
let sealedServerEnv = null;
|
|
787
|
+
await mkdir(path4.dirname(registryLockPath(request)), { recursive: true });
|
|
788
|
+
await withRegistryLock(request, async () => {
|
|
789
|
+
if (await pathExists(registration.registryRecord)) {
|
|
790
|
+
const existing = await readRegistryRecordForCapsule(request, "register");
|
|
791
|
+
assertRegistryRecordMatchesRequest(request, existing);
|
|
792
|
+
if (existing.status === "unregistered") {
|
|
793
|
+
await mkdir(path4.dirname(registration.registryRecord), { recursive: true });
|
|
794
|
+
await mkdir(registration.directories.releases, { recursive: true });
|
|
795
|
+
await mkdir(registration.directories.data, { recursive: true });
|
|
796
|
+
await writeUnavailableRoute(registration.lifecycle);
|
|
797
|
+
sealedServerEnv = await ensureHostSealedEnvKeyPair(registration, existing);
|
|
798
|
+
await writeRegistryRecordAtomic(registration.registryRecord, reactivateRegistrationRecord(existing, sealedServerEnv));
|
|
799
|
+
reactivated = true;
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
throw helperError(
|
|
803
|
+
"Hosted Capsule subname is already registered for this Hosted domain.",
|
|
804
|
+
`Choose a different Capsule subname for ${request.host.domain}.`
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
await mkdir(path4.dirname(registration.registryRecord), { recursive: true });
|
|
808
|
+
await mkdir(registration.directories.releases, { recursive: true });
|
|
809
|
+
await mkdir(registration.directories.data, { recursive: true });
|
|
810
|
+
await mkdir(registration.directories.logs, { recursive: true });
|
|
811
|
+
await writeUnavailableRoute(registration.lifecycle);
|
|
812
|
+
sealedServerEnv = await ensureHostSealedEnvKeyPair(registration);
|
|
813
|
+
await writeRegistryRecordAtomic(registration.registryRecord, createRegistrationRecord(registration, sealedServerEnv));
|
|
814
|
+
});
|
|
815
|
+
writeEnvelope({
|
|
816
|
+
ok: true,
|
|
817
|
+
data: {
|
|
818
|
+
registered: true,
|
|
819
|
+
reactivated,
|
|
820
|
+
authoritative: true,
|
|
821
|
+
capsule: {
|
|
822
|
+
subname: registration.subname,
|
|
823
|
+
domain: registration.domain,
|
|
824
|
+
hostedUrl: registration.hostedUrl,
|
|
825
|
+
remoteCapsuleId: registration.remoteCapsuleId
|
|
826
|
+
},
|
|
827
|
+
registryRecord: registration.registryRecord,
|
|
828
|
+
directories: registration.directories,
|
|
829
|
+
route: registration.route,
|
|
830
|
+
sealedServerEnv
|
|
831
|
+
},
|
|
832
|
+
error: null
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
async function rotateCapsuleSealedEnvKey(request) {
|
|
836
|
+
validateSealedEnvRotationRequest(request);
|
|
837
|
+
await mkdir(path4.dirname(registryLockPath(request)), { recursive: true });
|
|
838
|
+
let data;
|
|
839
|
+
await withRegistryLock(request, async () => {
|
|
840
|
+
const record = await readRegistryRecordForCapsule(request, "rotate-key");
|
|
841
|
+
assertRegistryRecordMatchesRequest(request, record);
|
|
842
|
+
if (record.status === "unregistered") {
|
|
843
|
+
throw helperError(
|
|
844
|
+
"Hosted Capsule is unregistered.",
|
|
845
|
+
`Run \`sporades host register ${request.capsule.subname} --host ${request.host.alias}\` before rotating the sealed-env key.`
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
const dataDirectory = path4.join(request.host.remoteRoot, "hosts", request.host.domain, "capsules", request.capsule.subname, "data");
|
|
849
|
+
const previousPublicKeyFingerprint = record.sealedServerEnv?.currentKeyFingerprint ?? null;
|
|
850
|
+
const sealedServerEnv = await generateHostSealedEnvKeyPair(dataDirectory);
|
|
851
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
852
|
+
const nextRecord = {
|
|
853
|
+
...record,
|
|
854
|
+
sealedServerEnv: { ...record.sealedServerEnv ?? {}, currentKeyFingerprint: sealedServerEnv.publicKeyFingerprint },
|
|
855
|
+
updatedAt: now
|
|
856
|
+
};
|
|
857
|
+
await writeRegistryRecordAtomic(registryPath(request), nextRecord);
|
|
858
|
+
const referenced = referencedSealedEnvKeyFingerprints(nextRecord);
|
|
859
|
+
referenced.add(sealedServerEnv.publicKeyFingerprint);
|
|
860
|
+
const cleanup = await cleanupUnreferencedHostSealedEnvKeys(dataDirectory, referenced);
|
|
861
|
+
data = {
|
|
862
|
+
rotated: true,
|
|
863
|
+
capsule: {
|
|
864
|
+
subname: request.capsule.subname,
|
|
865
|
+
domain: request.host.domain,
|
|
866
|
+
hostedUrl: record.hostedUrl ?? `${request.host.scheme ?? "https"}://${request.capsule.subname}.${request.host.domain}`,
|
|
867
|
+
remoteCapsuleId: record.remoteCapsuleId ?? `${request.host.domain}/${request.capsule.subname}`
|
|
868
|
+
},
|
|
869
|
+
sealedServerEnv: {
|
|
870
|
+
previousPublicKeyFingerprint,
|
|
871
|
+
publicKey: sealedServerEnv.publicKey,
|
|
872
|
+
publicKeyFingerprint: sealedServerEnv.publicKeyFingerprint,
|
|
873
|
+
publicKeyPath: sealedServerEnv.publicKeyPath
|
|
874
|
+
},
|
|
875
|
+
cleanup
|
|
876
|
+
};
|
|
877
|
+
});
|
|
878
|
+
writeEnvelope({ ok: true, data, error: null });
|
|
879
|
+
}
|
|
880
|
+
async function unregisterCapsule(request) {
|
|
881
|
+
validateUnregisterRequest(request);
|
|
882
|
+
const unregister = normaliseUnregister(request);
|
|
883
|
+
await mkdir(path4.dirname(registryLockPath(request)), { recursive: true });
|
|
884
|
+
let data;
|
|
885
|
+
await withRegistryLock(request, async () => {
|
|
886
|
+
const record = await readRegistryRecordForCapsule(request, "unregister");
|
|
887
|
+
assertRegistryRecordMatchesRequest(request, record);
|
|
888
|
+
if (record.status === "unregistered") {
|
|
889
|
+
data = createUnregisterResult(request, unregister, record, true);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
stopAndRemoveContainer(unregister.container.name);
|
|
893
|
+
const route = await removeManagedRoute(unregister.lifecycle, unregister.route.routeFile);
|
|
894
|
+
const now = /* @__PURE__ */ new Date();
|
|
895
|
+
const deleteAfter = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1e3).toISOString();
|
|
896
|
+
const nextRecord = {
|
|
897
|
+
...record,
|
|
898
|
+
status: "unregistered",
|
|
899
|
+
unregistered: true,
|
|
900
|
+
unregisteredAt: now.toISOString(),
|
|
901
|
+
deleteAfter,
|
|
902
|
+
updatedAt: now.toISOString()
|
|
903
|
+
};
|
|
904
|
+
try {
|
|
905
|
+
await writeRegistryRecordAtomic(unregister.registryRecord, nextRecord);
|
|
906
|
+
} catch (error) {
|
|
907
|
+
await restoreRemovedRoute(unregister.lifecycle, route);
|
|
908
|
+
throw error;
|
|
909
|
+
}
|
|
910
|
+
await finalizeRemovedRoute(route);
|
|
911
|
+
data = createUnregisterResult(request, unregister, nextRecord, false, route);
|
|
912
|
+
});
|
|
913
|
+
writeEnvelope({ ok: true, data, error: null });
|
|
914
|
+
}
|
|
915
|
+
async function deleteCapsule(request) {
|
|
916
|
+
validateDeleteRequest(request);
|
|
917
|
+
const deletion = normaliseDeletion(request);
|
|
918
|
+
await mkdir(path4.dirname(registryLockPath(request)), { recursive: true });
|
|
919
|
+
let data;
|
|
920
|
+
await withRegistryLock(request, async () => {
|
|
921
|
+
const record = await readOptionalRegistryRecordForCapsule(request);
|
|
922
|
+
if (record) {
|
|
923
|
+
assertRegistryRecordMatchesRequest(request, record);
|
|
924
|
+
if (record.status !== "unregistered") {
|
|
925
|
+
throw deletionRequiresUnregisterError(request);
|
|
926
|
+
}
|
|
927
|
+
} else if (await pathExists(deletion.route.routeFile) || await pathExists(deletion.directories.capsule)) {
|
|
928
|
+
throw deletionRequiresUnregisterError(request);
|
|
929
|
+
}
|
|
930
|
+
const route = await removePathIfPresent(deletion.route.routeFile);
|
|
931
|
+
if (route.removed) {
|
|
932
|
+
reloadCaddy(deletion.lifecycle);
|
|
933
|
+
}
|
|
934
|
+
const capsuleDirectory = await removePathIfPresent(deletion.directories.capsule, { recursive: true });
|
|
935
|
+
const registryRecord = await removePathIfPresent(deletion.registryRecord);
|
|
936
|
+
data = createDeleteResult(request, deletion, {
|
|
937
|
+
route,
|
|
938
|
+
capsuleDirectory,
|
|
939
|
+
registryRecord,
|
|
940
|
+
idempotent: !record && !route.removed && !capsuleDirectory.removed && !registryRecord.removed
|
|
941
|
+
});
|
|
942
|
+
});
|
|
943
|
+
writeEnvelope({ ok: true, data, error: null });
|
|
944
|
+
}
|
|
945
|
+
function deletionRequiresUnregisterError(request) {
|
|
946
|
+
return helperError(
|
|
947
|
+
"Hosted Capsule must be unregistered before deletion.",
|
|
948
|
+
`Run \`sporades host unregister ${request.capsule.subname} --host ${request.host.alias}\` before deleting Hosted Capsule storage.`
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
async function installRelease(request) {
|
|
952
|
+
const release = request.release;
|
|
953
|
+
validateInstallRequest(request);
|
|
954
|
+
const previousRecord = await verifyRegisteredCapsule(request);
|
|
955
|
+
const previousCurrentRelease = previousRecord.currentRelease?.id ? { id: previousRecord.currentRelease.id } : null;
|
|
956
|
+
validateReleaseArchive(request);
|
|
957
|
+
const paths = canonicalReleasePaths(request);
|
|
958
|
+
if (!paths.release) {
|
|
959
|
+
throw helperError("Invalid release install request.", "Update the Sporades CLI and retry `sporades host push`.");
|
|
960
|
+
}
|
|
961
|
+
validateSealedServerEnvPrivateKeyPath(release, paths);
|
|
962
|
+
await mkdir(paths.releases, { recursive: true });
|
|
963
|
+
await mkdir(paths.data, { recursive: true });
|
|
964
|
+
await prepareWritableDataPath(paths.data);
|
|
965
|
+
await mkdir(paths.logs, { recursive: true });
|
|
966
|
+
const tempReleaseDirectory = `${paths.release}.tmp-${process.pid}`;
|
|
967
|
+
const tempCurrentLink = `${paths.currentLink}.tmp-${process.pid}`;
|
|
968
|
+
await rm2(tempReleaseDirectory, { recursive: true, force: true });
|
|
969
|
+
await rm2(tempCurrentLink, { force: true });
|
|
970
|
+
await mkdir(tempReleaseDirectory, { recursive: true });
|
|
971
|
+
const extract = spawnSync2("tar", ["-xzf", release.remoteArchive, "-C", tempReleaseDirectory], {
|
|
972
|
+
encoding: "utf8"
|
|
973
|
+
});
|
|
974
|
+
if (extract.error || extract.status !== 0) {
|
|
975
|
+
await rm2(tempReleaseDirectory, { recursive: true, force: true });
|
|
976
|
+
throw helperError(
|
|
977
|
+
"Failed to extract Hosted Capsule release archive.",
|
|
978
|
+
"Upload the release again with `sporades host push` and check that tar is installed on the Host server."
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
await removeDiscardedArchiveMetadata(tempReleaseDirectory);
|
|
982
|
+
try {
|
|
983
|
+
await rename(tempReleaseDirectory, paths.release);
|
|
984
|
+
} catch (error) {
|
|
985
|
+
await rm2(tempReleaseDirectory, { recursive: true, force: true });
|
|
986
|
+
const details = errorDetails(error);
|
|
987
|
+
if (details.code === "EEXIST" || details.code === "ENOTEMPTY") {
|
|
988
|
+
throw helperError(
|
|
989
|
+
"Hosted Capsule release already exists.",
|
|
990
|
+
"Push again to generate a fresh immutable release ID."
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
throw error;
|
|
994
|
+
}
|
|
995
|
+
await symlink(paths.release, tempCurrentLink);
|
|
996
|
+
await rename(tempCurrentLink, paths.currentLink);
|
|
997
|
+
await installSealedServerEnvPrivateKey(release);
|
|
998
|
+
await recordReleaseUploaded(request, release);
|
|
999
|
+
let restartResult = null;
|
|
1000
|
+
let restartError = null;
|
|
1001
|
+
if (release.restart) {
|
|
1002
|
+
try {
|
|
1003
|
+
restartResult = await restartCapsule(request, { write: false });
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
restartError = error;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
const data = {
|
|
1009
|
+
installed: true,
|
|
1010
|
+
restartRequested: Boolean(release.restart),
|
|
1011
|
+
restarted: Boolean(restartResult?.restarted),
|
|
1012
|
+
capsule: {
|
|
1013
|
+
subname: request.capsule.subname,
|
|
1014
|
+
domain: request.host.domain,
|
|
1015
|
+
hostedUrl: release.hostedUrl
|
|
1016
|
+
},
|
|
1017
|
+
release: {
|
|
1018
|
+
id: release.id,
|
|
1019
|
+
directory: paths.release,
|
|
1020
|
+
currentLink: paths.currentLink,
|
|
1021
|
+
files: release.files,
|
|
1022
|
+
serverEnvIncluded: Boolean(release.serverEnvIncluded),
|
|
1023
|
+
...release.sealedServerEnvIncluded ? { sealedServerEnvIncluded: true } : {}
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
if (restartResult) {
|
|
1027
|
+
data.lifecycle = restartResult;
|
|
1028
|
+
}
|
|
1029
|
+
if (isVerificationRequested(request)) {
|
|
1030
|
+
const verificationResult = await verifyInstalledRelease(request, release, data, previousCurrentRelease, restartResult, restartError);
|
|
1031
|
+
writeEnvelope(verificationResult, !verificationResult.ok);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
if (release.restart && !restartResult) {
|
|
1035
|
+
writeEnvelope({
|
|
1036
|
+
ok: false,
|
|
1037
|
+
data,
|
|
1038
|
+
error: {
|
|
1039
|
+
message: errorDetails(restartError).message ?? "Hosted Capsule restart failed.",
|
|
1040
|
+
hint: errorDetails(restartError).hint ?? `Check Docker logs for ${normaliseLifecycle(request).container.name}; the route has been returned to the Hosted Capsule unavailable response.`
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
writeEnvelope({ ok: true, data, error: null });
|
|
1046
|
+
}
|
|
1047
|
+
function isVerificationRequested(request) {
|
|
1048
|
+
return request.verification?.enabled === true;
|
|
1049
|
+
}
|
|
1050
|
+
async function verifyInstalledRelease(request, release, installData, previousCurrentRelease, restartResult, restartError) {
|
|
1051
|
+
const currentAttemptedRelease = { id: release.id };
|
|
1052
|
+
const baseData = {
|
|
1053
|
+
...installData,
|
|
1054
|
+
previousCurrentRelease,
|
|
1055
|
+
currentAttemptedRelease
|
|
1056
|
+
};
|
|
1057
|
+
if (!restartResult) {
|
|
1058
|
+
const fallback = await maybeFallbackToPreviousRelease(request, release.id, previousCurrentRelease, restartError?.message ?? "Hosted Capsule restart failed.");
|
|
1059
|
+
return verificationFailureResult(
|
|
1060
|
+
request,
|
|
1061
|
+
release.id,
|
|
1062
|
+
{
|
|
1063
|
+
...baseData,
|
|
1064
|
+
verified: false,
|
|
1065
|
+
verification: {
|
|
1066
|
+
state: "failed",
|
|
1067
|
+
health: null
|
|
1068
|
+
},
|
|
1069
|
+
fallback
|
|
1070
|
+
},
|
|
1071
|
+
restartError?.message ?? "Hosted Capsule restart failed."
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
const healthResult = await evaluateCapsuleHealth(request, {
|
|
1075
|
+
timeoutMs: readVerificationHealthTimeoutMs(request)
|
|
1076
|
+
});
|
|
1077
|
+
if (!healthResult.ok) {
|
|
1078
|
+
await routeVerifiedFailureToUnavailable(request, release.id, healthResult.error?.message ?? "Hosted Capsule release verification failed.");
|
|
1079
|
+
const fallback = await maybeFallbackToPreviousRelease(
|
|
1080
|
+
request,
|
|
1081
|
+
release.id,
|
|
1082
|
+
previousCurrentRelease,
|
|
1083
|
+
healthResult.error?.message ?? "Hosted Capsule release verification failed."
|
|
1084
|
+
);
|
|
1085
|
+
return verificationFailureResult(
|
|
1086
|
+
request,
|
|
1087
|
+
release.id,
|
|
1088
|
+
{
|
|
1089
|
+
...baseData,
|
|
1090
|
+
verified: false,
|
|
1091
|
+
verification: {
|
|
1092
|
+
state: "failed",
|
|
1093
|
+
health: verificationHealthSummary(healthResult)
|
|
1094
|
+
},
|
|
1095
|
+
fallback
|
|
1096
|
+
},
|
|
1097
|
+
healthResult.error?.message ?? "Hosted Capsule release verification failed."
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
await recordReleaseVerified(request, release.id);
|
|
1101
|
+
return {
|
|
1102
|
+
ok: true,
|
|
1103
|
+
data: {
|
|
1104
|
+
...baseData,
|
|
1105
|
+
verified: true,
|
|
1106
|
+
verification: {
|
|
1107
|
+
state: "verified",
|
|
1108
|
+
health: verificationHealthSummary(healthResult)
|
|
1109
|
+
}
|
|
1110
|
+
},
|
|
1111
|
+
error: null
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
function readVerificationHealthTimeoutMs(request) {
|
|
1115
|
+
const value = Number(request.verification?.healthTimeoutMs ?? 1e4);
|
|
1116
|
+
if (!Number.isFinite(value) || value < 1) {
|
|
1117
|
+
return 1e4;
|
|
1118
|
+
}
|
|
1119
|
+
return Math.min(value, 6e4);
|
|
1120
|
+
}
|
|
1121
|
+
async function routeVerifiedFailureToUnavailable(request, releaseId, message) {
|
|
1122
|
+
const lifecycle = normaliseLifecycle(request);
|
|
1123
|
+
stopAndRemoveContainer(lifecycle.container.name);
|
|
1124
|
+
try {
|
|
1125
|
+
await writeUnavailableRoute(lifecycle);
|
|
1126
|
+
} finally {
|
|
1127
|
+
await recordReleaseVerificationFailed(request, releaseId, message);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
async function maybeFallbackToPreviousRelease(request, failedReleaseId, previousCurrentRelease, reason) {
|
|
1131
|
+
if (request.verification?.fallbackToPreviousRelease !== true || !previousCurrentRelease?.id) {
|
|
1132
|
+
return {
|
|
1133
|
+
applied: false,
|
|
1134
|
+
reason: request.verification?.fallbackToPreviousRelease === true ? "no-previous-release" : "not-configured"
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
const releaseId = previousCurrentRelease.id;
|
|
1138
|
+
const paths = canonicalRollbackPaths(request, releaseId);
|
|
1139
|
+
try {
|
|
1140
|
+
await assertRollbackReleaseFiles(request, paths.release);
|
|
1141
|
+
await switchCurrentReleaseLink(paths.currentLink, paths.release);
|
|
1142
|
+
let lifecycle = null;
|
|
1143
|
+
let restartError = null;
|
|
1144
|
+
try {
|
|
1145
|
+
lifecycle = await restartCapsule(request, { write: false });
|
|
1146
|
+
} catch (error) {
|
|
1147
|
+
restartError = error;
|
|
1148
|
+
}
|
|
1149
|
+
if (!lifecycle) {
|
|
1150
|
+
await restoreFailedReleaseAfterFallbackRestartFailure(request, failedReleaseId, releaseId, reason, restartError);
|
|
1151
|
+
return {
|
|
1152
|
+
applied: false,
|
|
1153
|
+
reason: "fallback-restart-failed",
|
|
1154
|
+
release: { id: releaseId },
|
|
1155
|
+
error: restartError ? { message: errorDetails(restartError).message, hint: errorDetails(restartError).hint ?? null } : null
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
await recordReleaseVerificationFallback(request, failedReleaseId, releaseId, reason);
|
|
1159
|
+
return { applied: true, release: { id: releaseId }, lifecycle };
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
const details = errorDetails(error);
|
|
1162
|
+
return {
|
|
1163
|
+
applied: false,
|
|
1164
|
+
reason: "fallback-failed",
|
|
1165
|
+
release: { id: releaseId },
|
|
1166
|
+
error: { message: details.message, hint: details.hint ?? null }
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
async function switchCurrentReleaseLink(currentLink, releaseDirectory) {
|
|
1171
|
+
const tempCurrentLink = `${currentLink}.tmp-${process.pid}`;
|
|
1172
|
+
await rm2(tempCurrentLink, { force: true });
|
|
1173
|
+
await symlink(releaseDirectory, tempCurrentLink);
|
|
1174
|
+
await rename(tempCurrentLink, currentLink);
|
|
1175
|
+
}
|
|
1176
|
+
async function restoreFailedReleaseAfterFallbackRestartFailure(request, failedReleaseId, fallbackReleaseId, reason, restartError) {
|
|
1177
|
+
const failedPaths = canonicalRollbackPaths(request, failedReleaseId);
|
|
1178
|
+
try {
|
|
1179
|
+
await switchCurrentReleaseLink(failedPaths.currentLink, failedPaths.release);
|
|
1180
|
+
} catch {
|
|
1181
|
+
}
|
|
1182
|
+
await recordReleaseVerificationFallbackFailed(
|
|
1183
|
+
request,
|
|
1184
|
+
failedReleaseId,
|
|
1185
|
+
fallbackReleaseId,
|
|
1186
|
+
restartError?.message ?? "Hosted Capsule fallback restart failed.",
|
|
1187
|
+
reason
|
|
1188
|
+
);
|
|
1189
|
+
try {
|
|
1190
|
+
await writeUnavailableRoute(normaliseLifecycle(request));
|
|
1191
|
+
} catch {
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
function verificationFailureResult(request, releaseId, data, message) {
|
|
1195
|
+
const rollbackGuidance = releaseVerificationRollbackGuidance(request, data.previousCurrentRelease);
|
|
1196
|
+
return {
|
|
1197
|
+
ok: false,
|
|
1198
|
+
data: {
|
|
1199
|
+
...data,
|
|
1200
|
+
rollbackGuidance
|
|
1201
|
+
},
|
|
1202
|
+
error: {
|
|
1203
|
+
message: "Hosted Capsule release verification failed.",
|
|
1204
|
+
hint: rollbackGuidance ? `Run \`${rollbackGuidance.command}\` to explicitly roll back to the previous current release.` : `Inspect \`sporades host releases ${request.capsule.subname} --host ${request.host.alias} --json\` and choose an explicit rollback target.`,
|
|
1205
|
+
details: {
|
|
1206
|
+
releaseId,
|
|
1207
|
+
cause: message,
|
|
1208
|
+
fallback: data.fallback ?? { applied: false, reason: "not-configured" }
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
function releaseVerificationRollbackGuidance(request, previousCurrentRelease) {
|
|
1214
|
+
if (!previousCurrentRelease?.id) {
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
return {
|
|
1218
|
+
previousReleaseId: previousCurrentRelease.id,
|
|
1219
|
+
command: `sporades host rollback ${request.capsule.subname} ${previousCurrentRelease.id} --host ${request.host.alias}`
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
function verificationHealthSummary(result) {
|
|
1223
|
+
if (result.ok) {
|
|
1224
|
+
return {
|
|
1225
|
+
route: {
|
|
1226
|
+
url: result.data.route.url,
|
|
1227
|
+
responding: result.data.route.responding === true
|
|
1228
|
+
},
|
|
1229
|
+
runtime: result.data.runtime
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
return {
|
|
1233
|
+
failure: result.data?.failure ?? "verification-failure",
|
|
1234
|
+
route: {
|
|
1235
|
+
url: result.data?.route?.url ?? null,
|
|
1236
|
+
responding: false
|
|
1237
|
+
},
|
|
1238
|
+
runtime: result.data?.runtime ?? null
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
async function startCapsule(request, options = {}) {
|
|
1242
|
+
validateLifecycleRequest(request);
|
|
1243
|
+
const registryRecord = await verifyRegisteredCapsule(request, "lifecycle");
|
|
1244
|
+
const paths = canonicalReleasePaths(request);
|
|
1245
|
+
const releaseId = await currentReleaseId(paths.currentLink, request);
|
|
1246
|
+
const lifecycle = normaliseLifecycle(request, registryRecord);
|
|
1247
|
+
await mkdir(paths.data, { recursive: true });
|
|
1248
|
+
await prepareWritableDataPath(paths.data);
|
|
1249
|
+
await recordReleaseStartAttempt(request, releaseId);
|
|
1250
|
+
stopAndRemoveContainer(lifecycle.container.name);
|
|
1251
|
+
ensureHostedBaseImage(lifecycle);
|
|
1252
|
+
const runArgs = await dockerRunArgs(lifecycle, releaseId);
|
|
1253
|
+
const run = runDocker(runArgs);
|
|
1254
|
+
if (!run.ok) {
|
|
1255
|
+
await recordFailedStartAndUnavailableRoute(request, lifecycle, releaseId, "Hosted Capsule container failed to start.");
|
|
1256
|
+
const result = {
|
|
1257
|
+
ok: false,
|
|
1258
|
+
data: null,
|
|
1259
|
+
error: {
|
|
1260
|
+
message: "Hosted Capsule container failed to start.",
|
|
1261
|
+
hint: `Check Docker logs for ${lifecycle.container.name}, then retry \`sporades host start ${request.capsule.subname} --host ${request.host.alias}\`.`
|
|
1262
|
+
}
|
|
1263
|
+
};
|
|
1264
|
+
if (options.write !== false) {
|
|
1265
|
+
writeEnvelope(result);
|
|
1266
|
+
}
|
|
1267
|
+
return null;
|
|
1268
|
+
}
|
|
1269
|
+
const running = checkContainerRunning(lifecycle.container.name);
|
|
1270
|
+
if (!running) {
|
|
1271
|
+
await recordFailedStartAndUnavailableRoute(request, lifecycle, releaseId, "Hosted Capsule container did not stay running.");
|
|
1272
|
+
const result = {
|
|
1273
|
+
ok: false,
|
|
1274
|
+
data: null,
|
|
1275
|
+
error: {
|
|
1276
|
+
message: "Hosted Capsule container did not stay running.",
|
|
1277
|
+
hint: `Check Docker logs for ${lifecycle.container.name}; the route has been returned to the Hosted Capsule unavailable response.`
|
|
1278
|
+
}
|
|
1279
|
+
};
|
|
1280
|
+
if (options.write !== false) {
|
|
1281
|
+
writeEnvelope(result);
|
|
1282
|
+
}
|
|
1283
|
+
return null;
|
|
1284
|
+
}
|
|
1285
|
+
const publishedPort = inspectLoopbackPublishedPort(lifecycle.container.name, lifecycle.routes.running.port ?? 4e3);
|
|
1286
|
+
if (!publishedPort) {
|
|
1287
|
+
stopAndRemoveContainer(lifecycle.container.name);
|
|
1288
|
+
await recordFailedStartAndUnavailableRoute(request, lifecycle, releaseId, "Docker did not report a loopback published port for Hosted Capsule.");
|
|
1289
|
+
const result = {
|
|
1290
|
+
ok: false,
|
|
1291
|
+
data: null,
|
|
1292
|
+
error: {
|
|
1293
|
+
message: "Docker did not report a loopback published port for Hosted Capsule.",
|
|
1294
|
+
hint: `Ensure Docker published container port 4000 on 127.0.0.1, then retry \`sporades host start ${request.capsule.subname} --host ${request.host.alias}\`.`
|
|
1295
|
+
}
|
|
1296
|
+
};
|
|
1297
|
+
if (options.write !== false) {
|
|
1298
|
+
writeEnvelope(result);
|
|
1299
|
+
}
|
|
1300
|
+
return null;
|
|
1301
|
+
}
|
|
1302
|
+
const runtimeProbe = await ensureRuntimeProbeCredential(request);
|
|
1303
|
+
const runningRoute = loopbackRunningRoute({ ...lifecycle.routes.running, runtimeProbe }, publishedPort);
|
|
1304
|
+
try {
|
|
1305
|
+
await writeRunningRoute(lifecycle, runningRoute);
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
await recordReleaseFailure(request, releaseId, String(errorDetails(error).message ?? "Failed to apply Hosted Capsule route."));
|
|
1308
|
+
throw error;
|
|
1309
|
+
}
|
|
1310
|
+
await recordReleaseStarted(request, releaseId);
|
|
1311
|
+
const data = {
|
|
1312
|
+
started: true,
|
|
1313
|
+
restarted: false,
|
|
1314
|
+
capsule: capsuleData(request, lifecycle),
|
|
1315
|
+
release: { id: releaseId },
|
|
1316
|
+
container: {
|
|
1317
|
+
id: run.stdout.trim(),
|
|
1318
|
+
name: lifecycle.container.name,
|
|
1319
|
+
network: lifecycle.container.network,
|
|
1320
|
+
image: lifecycle.container.image,
|
|
1321
|
+
user: lifecycle.container.user,
|
|
1322
|
+
labels: lifecycle.container.labels,
|
|
1323
|
+
baseImage: lifecycle.container.baseImage,
|
|
1324
|
+
running: true,
|
|
1325
|
+
publishedPort
|
|
1326
|
+
},
|
|
1327
|
+
route: publicRouteData(runningRoute),
|
|
1328
|
+
restartPolicy: restartPolicyStatus("hosted")
|
|
1329
|
+
};
|
|
1330
|
+
if (options.write !== false) {
|
|
1331
|
+
writeEnvelope({ ok: true, data, error: null });
|
|
1332
|
+
}
|
|
1333
|
+
return data;
|
|
1334
|
+
}
|
|
1335
|
+
async function installSealedServerEnvPrivateKey(release) {
|
|
1336
|
+
const privateKey = release.sealedServerEnv?.privateKey;
|
|
1337
|
+
const privateKeyPath = release.sealedServerEnv?.privateKeyPath;
|
|
1338
|
+
if (!release.sealedServerEnvIncluded || !privateKey || !privateKeyPath) {
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
await mkdir(path4.dirname(privateKeyPath), { recursive: true });
|
|
1342
|
+
await writeFile(privateKeyPath, privateKey, { mode: 384 });
|
|
1343
|
+
}
|
|
1344
|
+
function validateSealedServerEnvPrivateKeyPath(release, paths) {
|
|
1345
|
+
if (!release.sealedServerEnvIncluded) {
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
if (!release.sealedServerEnv?.privateKey && !release.sealedServerEnv?.privateKeyPath) {
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
const privateKeyPath = release.sealedServerEnv?.privateKeyPath;
|
|
1352
|
+
const expectedPrivateKeyPath = path4.join(paths.data, "sealed-server-env", "server-env.private.pem");
|
|
1353
|
+
if (typeof privateKeyPath !== "string" || path4.resolve(privateKeyPath) !== path4.resolve(expectedPrivateKeyPath)) {
|
|
1354
|
+
throw helperError(
|
|
1355
|
+
"Invalid Sealed Server env private key path.",
|
|
1356
|
+
"Update the Sporades CLI and retry `sporades host push`."
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
async function healthCapsule(request) {
|
|
1361
|
+
writeEnvelope(await evaluateCapsuleHealth(request));
|
|
1362
|
+
}
|
|
1363
|
+
async function evaluateCapsuleHealth(request, options = {}) {
|
|
1364
|
+
validateHealthRequest(request);
|
|
1365
|
+
let health = normaliseHealth(request);
|
|
1366
|
+
let record;
|
|
1367
|
+
try {
|
|
1368
|
+
record = await readRegistryRecordForCapsule(request, "health");
|
|
1369
|
+
} catch (error) {
|
|
1370
|
+
if (errorDetails(error).message === "Hosted Capsule is not registered.") {
|
|
1371
|
+
return unregisteredHealthFailure(request, health);
|
|
1372
|
+
}
|
|
1373
|
+
throw error;
|
|
1374
|
+
}
|
|
1375
|
+
assertRegistryRecordMatchesRequest(request, record);
|
|
1376
|
+
health = normaliseHealth(request, record);
|
|
1377
|
+
if (record.status === "unregistered") {
|
|
1378
|
+
return unregisteredHealthFailure(request, health);
|
|
1379
|
+
}
|
|
1380
|
+
if (!record.currentRelease?.id) {
|
|
1381
|
+
return healthFailure(
|
|
1382
|
+
request,
|
|
1383
|
+
health,
|
|
1384
|
+
"no-current-release",
|
|
1385
|
+
"Hosted Capsule has no current release.",
|
|
1386
|
+
`Run \`sporades host push --host ${request.host.alias} --subname ${request.capsule.subname}\`, then retry health.`
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
const running = checkContainerRunning(health.container.name);
|
|
1390
|
+
if (!running) {
|
|
1391
|
+
await routeRuntimeExhaustionToUnavailable(request, record, health);
|
|
1392
|
+
return healthFailure(
|
|
1393
|
+
request,
|
|
1394
|
+
health,
|
|
1395
|
+
"stopped-container",
|
|
1396
|
+
"Hosted Capsule has no running container.",
|
|
1397
|
+
`Run \`sporades host start ${request.capsule.subname} --host ${request.host.alias}\`, then retry health.`
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
const runtimeProbe = readRuntimeProbeCredential(record);
|
|
1401
|
+
if (!runtimeProbe) {
|
|
1402
|
+
return healthFailure(
|
|
1403
|
+
request,
|
|
1404
|
+
health,
|
|
1405
|
+
"route-failure",
|
|
1406
|
+
"Hosted Capsule runtime probe is not configured.",
|
|
1407
|
+
`Restart the Hosted Capsule with \`sporades host restart ${request.capsule.subname} --host ${request.host.alias}\`, then retry health.`
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
let response;
|
|
1411
|
+
try {
|
|
1412
|
+
response = await fetch(health.runtimeHealthUrl, {
|
|
1413
|
+
headers: {
|
|
1414
|
+
accept: "application/json",
|
|
1415
|
+
[runtimeProbe.header]: runtimeProbe.token
|
|
1416
|
+
},
|
|
1417
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 1e4)
|
|
1418
|
+
});
|
|
1419
|
+
} catch {
|
|
1420
|
+
return healthFailure(
|
|
1421
|
+
request,
|
|
1422
|
+
health,
|
|
1423
|
+
"route-failure",
|
|
1424
|
+
"Hosted Capsule route did not respond to runtime health.",
|
|
1425
|
+
"Check DNS, Caddy, and the Hosted Capsule route, then retry health."
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
if (!response.ok) {
|
|
1429
|
+
return healthFailure(
|
|
1430
|
+
request,
|
|
1431
|
+
health,
|
|
1432
|
+
"route-failure",
|
|
1433
|
+
"Hosted Capsule route returned an HTTP failure for runtime health.",
|
|
1434
|
+
"Check Caddy routing and Hosted Capsule logs, then retry health.",
|
|
1435
|
+
{ statusCode: response.status }
|
|
1436
|
+
);
|
|
1437
|
+
}
|
|
1438
|
+
let body;
|
|
1439
|
+
try {
|
|
1440
|
+
body = JSON.parse(await response.text());
|
|
1441
|
+
} catch {
|
|
1442
|
+
return healthFailure(
|
|
1443
|
+
request,
|
|
1444
|
+
health,
|
|
1445
|
+
"runtime-failure",
|
|
1446
|
+
"Hosted Capsule runtime health returned invalid JSON.",
|
|
1447
|
+
"Check Hosted Capsule logs, then retry health."
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
const runtime = normaliseRuntimeHealthBody(body);
|
|
1451
|
+
if (!runtime.valid) {
|
|
1452
|
+
return healthFailure(
|
|
1453
|
+
request,
|
|
1454
|
+
health,
|
|
1455
|
+
"runtime-failure",
|
|
1456
|
+
"Hosted Capsule runtime health had an unexpected shape.",
|
|
1457
|
+
"Update the Hosted Capsule release and retry health."
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
if (!runtime.checks.sqlite.ok) {
|
|
1461
|
+
return healthFailure(
|
|
1462
|
+
request,
|
|
1463
|
+
health,
|
|
1464
|
+
"sqlite-failure",
|
|
1465
|
+
"Hosted Capsule SQLite health check failed.",
|
|
1466
|
+
"Check the Hosted Capsule data volume and runtime logs, then retry health.",
|
|
1467
|
+
{ runtime: runtime.safe }
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
if (!runtime.checks.fileStorage.ok) {
|
|
1471
|
+
return healthFailure(
|
|
1472
|
+
request,
|
|
1473
|
+
health,
|
|
1474
|
+
"file-storage-failure",
|
|
1475
|
+
"Hosted Capsule file storage health check failed.",
|
|
1476
|
+
"Check the Hosted Capsule data volume permissions, then retry health.",
|
|
1477
|
+
{ runtime: runtime.safe }
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
if (!body.ok || !runtime.ready) {
|
|
1481
|
+
return healthFailure(
|
|
1482
|
+
request,
|
|
1483
|
+
health,
|
|
1484
|
+
"runtime-failure",
|
|
1485
|
+
"Hosted Capsule runtime is not ready.",
|
|
1486
|
+
"Check Hosted Capsule logs, then retry health.",
|
|
1487
|
+
{ runtime: runtime.safe }
|
|
1488
|
+
);
|
|
1489
|
+
}
|
|
1490
|
+
return {
|
|
1491
|
+
ok: true,
|
|
1492
|
+
data: {
|
|
1493
|
+
capsule: {
|
|
1494
|
+
subname: request.capsule.subname,
|
|
1495
|
+
domain: request.host.domain,
|
|
1496
|
+
hostedUrl: health.hostedUrl,
|
|
1497
|
+
remoteCapsuleId: health.remoteCapsuleId,
|
|
1498
|
+
registered: true
|
|
1499
|
+
},
|
|
1500
|
+
release: { id: record.currentRelease.id, current: true },
|
|
1501
|
+
container: { name: health.container.name, running: true },
|
|
1502
|
+
route: { url: health.runtimeHealthUrl, responding: true },
|
|
1503
|
+
runtime: runtime.safe
|
|
1504
|
+
},
|
|
1505
|
+
error: null
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
async function routeRuntimeExhaustionToUnavailable(request, record, health) {
|
|
1509
|
+
const inspected = inspectContainerLifecycle(health.container.name);
|
|
1510
|
+
const policy = restartPolicyForMode("hosted");
|
|
1511
|
+
if (!Number.isFinite(inspected.restartCount) || inspected.restartCount < policy.maxAttempts) {
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
const releaseId = record.currentRelease?.id;
|
|
1515
|
+
if (!releaseId) {
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
try {
|
|
1519
|
+
await writeUnavailableRoute(normaliseLifecycle(request, record));
|
|
1520
|
+
await recordReleaseFailure(
|
|
1521
|
+
request,
|
|
1522
|
+
releaseId,
|
|
1523
|
+
`Hosted Capsule runtime exhausted ${policy.dockerRestart} restart policy.`
|
|
1524
|
+
);
|
|
1525
|
+
} catch {
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
async function stopCapsule(request, options = {}) {
|
|
1529
|
+
validateLifecycleRequest(request);
|
|
1530
|
+
await verifyRegisteredCapsule(request, "lifecycle");
|
|
1531
|
+
const lifecycle = normaliseLifecycle(request);
|
|
1532
|
+
stopAndRemoveContainer(lifecycle.container.name);
|
|
1533
|
+
await writeUnavailableRoute(lifecycle);
|
|
1534
|
+
await updateRegistryStatus(request, "stopped");
|
|
1535
|
+
const data = {
|
|
1536
|
+
stopped: true,
|
|
1537
|
+
capsule: capsuleData(request, lifecycle),
|
|
1538
|
+
container: { name: lifecycle.container.name, running: false },
|
|
1539
|
+
route: lifecycle.routes.unavailable
|
|
1540
|
+
};
|
|
1541
|
+
if (options.write !== false) {
|
|
1542
|
+
writeEnvelope({ ok: true, data, error: null });
|
|
1543
|
+
}
|
|
1544
|
+
return data;
|
|
1545
|
+
}
|
|
1546
|
+
async function restartCapsule(request, options = {}) {
|
|
1547
|
+
validateLifecycleRequest(request);
|
|
1548
|
+
const lifecycle = normaliseLifecycle(request);
|
|
1549
|
+
stopAndRemoveContainer(lifecycle.container.name);
|
|
1550
|
+
const startResult = await startCapsule(request, { write: false });
|
|
1551
|
+
if (!startResult) {
|
|
1552
|
+
if (options.write !== false) {
|
|
1553
|
+
writeEnvelope({
|
|
1554
|
+
ok: false,
|
|
1555
|
+
data: null,
|
|
1556
|
+
error: {
|
|
1557
|
+
message: "Hosted Capsule restart failed.",
|
|
1558
|
+
hint: `Check Docker logs for ${lifecycle.container.name}; the route has been returned to the Hosted Capsule unavailable response.`
|
|
1559
|
+
}
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
return null;
|
|
1563
|
+
}
|
|
1564
|
+
const data = { ...startResult, restarted: true };
|
|
1565
|
+
if (options.write !== false) {
|
|
1566
|
+
writeEnvelope({ ok: true, data, error: null });
|
|
1567
|
+
}
|
|
1568
|
+
return data;
|
|
1569
|
+
}
|
|
1570
|
+
async function statsCapsule(request) {
|
|
1571
|
+
validateStatsRequest(request);
|
|
1572
|
+
const registryRecord = await verifyRegisteredCapsule(request, "stats");
|
|
1573
|
+
const stats = normaliseStats(request);
|
|
1574
|
+
const runningState = inspectContainerRunning(stats.container.name);
|
|
1575
|
+
if (!runningState.ok) {
|
|
1576
|
+
throw helperError(
|
|
1577
|
+
"Failed to read Hosted Capsule Docker stats.",
|
|
1578
|
+
`Check Docker on the Host server and retry \`sporades host stats ${request.capsule.subname} --host ${request.host.alias}\`.`
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
if (!runningState.running) {
|
|
1582
|
+
throw helperError(
|
|
1583
|
+
"Hosted Capsule has no running container.",
|
|
1584
|
+
`Run \`sporades host start ${request.capsule.subname} --host ${request.host.alias}\`, then retry stats.`
|
|
1585
|
+
);
|
|
1586
|
+
}
|
|
1587
|
+
const result = runDocker(["stats", "--no-stream", "--format", "json", stats.container.name]);
|
|
1588
|
+
if (!result.ok) {
|
|
1589
|
+
throw helperError(
|
|
1590
|
+
"Failed to read Hosted Capsule Docker stats.",
|
|
1591
|
+
`Check Docker on the Host server and retry \`sporades host stats ${request.capsule.subname} --host ${request.host.alias}\`.`
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
let raw;
|
|
1595
|
+
try {
|
|
1596
|
+
raw = JSON.parse(result.stdout);
|
|
1597
|
+
} catch {
|
|
1598
|
+
throw helperError(
|
|
1599
|
+
"Hosted Capsule Docker stats were not valid JSON.",
|
|
1600
|
+
"Update Docker or reinstall the Sporades Host helper on the Host server."
|
|
1601
|
+
);
|
|
1602
|
+
}
|
|
1603
|
+
const data = {
|
|
1604
|
+
capsule: {
|
|
1605
|
+
subname: request.capsule.subname,
|
|
1606
|
+
domain: request.host.domain,
|
|
1607
|
+
hostedUrl: stats.hostedUrl,
|
|
1608
|
+
remoteCapsuleId: stats.remoteCapsuleId
|
|
1609
|
+
},
|
|
1610
|
+
container: {
|
|
1611
|
+
name: stats.container.name,
|
|
1612
|
+
running: true
|
|
1613
|
+
},
|
|
1614
|
+
stats: normaliseDockerStats(raw),
|
|
1615
|
+
lifecycle: readCapsuleLifecycle(request, registryRecord, stats.container.name, true),
|
|
1616
|
+
raw
|
|
1617
|
+
};
|
|
1618
|
+
writeEnvelope({ ok: true, data, error: null });
|
|
1619
|
+
}
|
|
1620
|
+
async function listReleases(request) {
|
|
1621
|
+
validateReleaseListRequest(request);
|
|
1622
|
+
const record = await readRegistryRecordForCapsule(request, "releases");
|
|
1623
|
+
assertRegistryRecordMatchesRequest(request, record);
|
|
1624
|
+
const releases = normaliseReleaseHistory(record).map((release) => markCurrentReleaseEntry(release, record.currentRelease?.id ?? null)).sort(compareReleasesNewestFirst);
|
|
1625
|
+
writeEnvelope({
|
|
1626
|
+
ok: true,
|
|
1627
|
+
data: {
|
|
1628
|
+
capsule: {
|
|
1629
|
+
subname: record.subname,
|
|
1630
|
+
domain: record.domain,
|
|
1631
|
+
hostedUrl: record.hostedUrl ?? `${request.host.scheme ?? "https"}://${record.subname}.${request.host.domain}`,
|
|
1632
|
+
remoteCapsuleId: record.remoteCapsuleId ?? `${request.host.domain}/${record.subname}`
|
|
1633
|
+
},
|
|
1634
|
+
currentRelease: publicCurrentRelease(record),
|
|
1635
|
+
releases
|
|
1636
|
+
},
|
|
1637
|
+
error: null
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
async function rollbackRelease(request) {
|
|
1641
|
+
validateRollbackRequest(request);
|
|
1642
|
+
const record = await readRegistryRecordForCapsule(request, "rollback");
|
|
1643
|
+
assertRegistryRecordMatchesRequest(request, record);
|
|
1644
|
+
if (record.status === "unregistered") {
|
|
1645
|
+
throw helperError(
|
|
1646
|
+
"Hosted Capsule is unregistered.",
|
|
1647
|
+
`Run \`sporades host register ${request.capsule.subname} --host ${request.host.alias}\` before retrying this command.`
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
const releases = normaliseReleaseHistory(record);
|
|
1651
|
+
if (releases.length === 0) {
|
|
1652
|
+
throw helperError(
|
|
1653
|
+
"Hosted Capsule has no release history.",
|
|
1654
|
+
`Push a release before running \`sporades host rollback ${request.capsule.subname} <release-id> --host ${request.host.alias}\`.`
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
const releaseId = request.rollback?.releaseId;
|
|
1658
|
+
if (!releaseId) {
|
|
1659
|
+
throw helperError("Invalid Hosted Capsule rollback request.", "Update the Sporades CLI and retry `sporades host rollback`.");
|
|
1660
|
+
}
|
|
1661
|
+
const selectedRelease = releases.find((release) => release.id === releaseId);
|
|
1662
|
+
if (!selectedRelease) {
|
|
1663
|
+
throw helperError(
|
|
1664
|
+
"Hosted Capsule release is not recorded.",
|
|
1665
|
+
`Run \`sporades host releases ${request.capsule.subname} --host ${request.host.alias} --json\` and choose a recorded release ID.`
|
|
1666
|
+
);
|
|
1667
|
+
}
|
|
1668
|
+
const paths = canonicalRollbackPaths(request, releaseId);
|
|
1669
|
+
await assertRollbackReleaseFiles(request, paths.release);
|
|
1670
|
+
const previousCurrentRelease = record.currentRelease ?? null;
|
|
1671
|
+
const tempCurrentLink = `${paths.currentLink}.tmp-${process.pid}`;
|
|
1672
|
+
await rm2(tempCurrentLink, { force: true });
|
|
1673
|
+
await symlink(paths.release, tempCurrentLink);
|
|
1674
|
+
await rename(tempCurrentLink, paths.currentLink);
|
|
1675
|
+
await recordReleaseRollbackSelected(request, releaseId);
|
|
1676
|
+
let lifecycle = null;
|
|
1677
|
+
let restartError = null;
|
|
1678
|
+
try {
|
|
1679
|
+
lifecycle = await restartCapsule(request, { write: false });
|
|
1680
|
+
} catch (error) {
|
|
1681
|
+
restartError = error;
|
|
1682
|
+
}
|
|
1683
|
+
const data = {
|
|
1684
|
+
rolledBack: Boolean(lifecycle),
|
|
1685
|
+
capsule: {
|
|
1686
|
+
subname: request.capsule.subname,
|
|
1687
|
+
domain: request.host.domain,
|
|
1688
|
+
hostedUrl: selectedRelease.source.hostedUrl ?? `${request.host.scheme ?? "https"}://${request.capsule.subname}.${request.host.domain}`,
|
|
1689
|
+
remoteCapsuleId: selectedRelease.source.remoteCapsuleId ?? `${request.host.domain}/${request.capsule.subname}`
|
|
1690
|
+
},
|
|
1691
|
+
previousCurrentRelease,
|
|
1692
|
+
currentRelease: { ...record.currentRelease ?? {}, id: releaseId }
|
|
1693
|
+
};
|
|
1694
|
+
if (lifecycle) {
|
|
1695
|
+
data.lifecycle = lifecycle;
|
|
1696
|
+
writeEnvelope({ ok: true, data, error: null });
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
try {
|
|
1700
|
+
await writeUnavailableRoute(normaliseLifecycle(request));
|
|
1701
|
+
} catch (error) {
|
|
1702
|
+
restartError = restartError ?? error;
|
|
1703
|
+
}
|
|
1704
|
+
data.rolledBack = false;
|
|
1705
|
+
writeEnvelope({
|
|
1706
|
+
ok: false,
|
|
1707
|
+
data,
|
|
1708
|
+
error: {
|
|
1709
|
+
message: errorDetails(restartError).message ?? "Hosted Capsule rollback start failed.",
|
|
1710
|
+
hint: errorDetails(restartError).hint ?? `Previous current release was ${previousCurrentRelease?.id ?? "none"}. Check Docker logs for ${normaliseLifecycle(request).container.name}; the route has been returned to the Hosted Capsule unavailable response.`
|
|
1711
|
+
}
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
async function statsHost(request) {
|
|
1715
|
+
validateHostStatsRequest(request);
|
|
1716
|
+
const records = await readCapsuleRegistryRecords(request);
|
|
1717
|
+
const dockerAvailable = checkDockerAvailable();
|
|
1718
|
+
const caddyAvailable = checkCaddyAvailable();
|
|
1719
|
+
const dockerStates = dockerAvailable ? records.map((record) => lookupCapsuleDockerState(request, record)) : [];
|
|
1720
|
+
const data = {
|
|
1721
|
+
host: {
|
|
1722
|
+
alias: request.host.alias,
|
|
1723
|
+
domain: request.host.domain,
|
|
1724
|
+
scheme: request.host.scheme ?? "https",
|
|
1725
|
+
remoteRoot: request.host.remoteRoot
|
|
1726
|
+
},
|
|
1727
|
+
resources: {
|
|
1728
|
+
disk: await readHostDiskStats(request.host.remoteRoot),
|
|
1729
|
+
memory: readHostMemoryStats(),
|
|
1730
|
+
load: readHostLoadStats()
|
|
1731
|
+
},
|
|
1732
|
+
services: {
|
|
1733
|
+
docker: { available: dockerAvailable },
|
|
1734
|
+
caddy: { available: caddyAvailable }
|
|
1735
|
+
},
|
|
1736
|
+
capsules: countHostedCapsules(records, dockerStates)
|
|
1737
|
+
};
|
|
1738
|
+
writeEnvelope({ ok: true, data, error: null });
|
|
1739
|
+
}
|
|
1740
|
+
async function logsHost(request) {
|
|
1741
|
+
validateHostLogsRequest(request, hostHelperConfig.logs);
|
|
1742
|
+
const logs = normaliseHostLogs(request);
|
|
1743
|
+
if (logs.source === "stdout" || logs.source === "stderr") {
|
|
1744
|
+
const container = logs.container;
|
|
1745
|
+
if (!container) {
|
|
1746
|
+
throw helperError("Hosted Capsule log target is missing.", "Pass a Hosted Capsule subname when reading stdout or stderr logs.");
|
|
1747
|
+
}
|
|
1748
|
+
const entries = readDockerStreamLogs(logs);
|
|
1749
|
+
writeEnvelope({ ok: true, data: { lineCount: logs.lines, source: logs.source, container: container.name, entries }, error: null });
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
const fileEntries = await readManagedCaddyAccessLog(logs);
|
|
1753
|
+
if (fileEntries) {
|
|
1754
|
+
writeEnvelope({ ok: true, data: { lineCount: logs.lines, source: "http", entries: fileEntries }, error: null });
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
if (logs.capsuleScoped) {
|
|
1758
|
+
throw unavailableCapsuleHttpLogsError(logs);
|
|
1759
|
+
}
|
|
1760
|
+
const journalEntries = readCaddyJournalLogs(logs);
|
|
1761
|
+
if (journalEntries) {
|
|
1762
|
+
writeEnvelope({ ok: true, data: { lineCount: logs.lines, source: "http", entries: journalEntries }, error: null });
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
throw unavailableCaddyLogsError(request);
|
|
1766
|
+
}
|
|
1767
|
+
async function listCapsules(request) {
|
|
1768
|
+
validateListRequest(request);
|
|
1769
|
+
const records = await readCapsuleRegistryRecords(request);
|
|
1770
|
+
const capsules = [];
|
|
1771
|
+
for (const record of records) {
|
|
1772
|
+
if (record.status === "unregistered") {
|
|
1773
|
+
continue;
|
|
1774
|
+
}
|
|
1775
|
+
const capsule = {
|
|
1776
|
+
subname: record.subname,
|
|
1777
|
+
domain: record.domain,
|
|
1778
|
+
hostedUrl: record.hostedUrl ?? `${request.host.scheme ?? "https"}://${record.subname}.${request.host.domain}`,
|
|
1779
|
+
registry: {
|
|
1780
|
+
remoteCapsuleId: record.remoteCapsuleId ?? `${request.host.domain}/${record.subname}`,
|
|
1781
|
+
createdAt: record.createdAt ?? null,
|
|
1782
|
+
updatedAt: record.updatedAt ?? null,
|
|
1783
|
+
status: record.status ?? "registered",
|
|
1784
|
+
...record.sealedServerEnv ? { sealedServerEnv: publicRegistrySealedServerEnv(record.sealedServerEnv) } : {}
|
|
1785
|
+
},
|
|
1786
|
+
currentRelease: publicCurrentRelease(record),
|
|
1787
|
+
docker: lookupCapsuleDockerState(request, record)
|
|
1788
|
+
};
|
|
1789
|
+
const sealedServerEnv = await inspectHostSealedEnvKey(record, request.host.remoteRoot);
|
|
1790
|
+
if (sealedServerEnv) {
|
|
1791
|
+
capsule.sealedServerEnv = sealedServerEnv;
|
|
1792
|
+
}
|
|
1793
|
+
capsule.baseImage = normaliseRecordBaseImage(record, capsule.docker);
|
|
1794
|
+
capsules.push(capsule);
|
|
1795
|
+
}
|
|
1796
|
+
writeEnvelope({
|
|
1797
|
+
ok: true,
|
|
1798
|
+
data: {
|
|
1799
|
+
host: {
|
|
1800
|
+
alias: request.host.alias,
|
|
1801
|
+
domain: request.host.domain,
|
|
1802
|
+
scheme: request.host.scheme ?? "https",
|
|
1803
|
+
remoteRoot: request.host.remoteRoot
|
|
1804
|
+
},
|
|
1805
|
+
capsules
|
|
1806
|
+
},
|
|
1807
|
+
error: null
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
function createUnregisterResult(request, unregister, record, idempotent, route = null) {
|
|
1811
|
+
return {
|
|
1812
|
+
unregistered: true,
|
|
1813
|
+
idempotent,
|
|
1814
|
+
capsule: {
|
|
1815
|
+
subname: request.capsule.subname,
|
|
1816
|
+
domain: request.host.domain,
|
|
1817
|
+
hostedUrl: unregister.hostedUrl,
|
|
1818
|
+
remoteCapsuleId: unregister.remoteCapsuleId
|
|
1819
|
+
},
|
|
1820
|
+
registryRecord: unregister.registryRecord,
|
|
1821
|
+
directories: unregister.directories,
|
|
1822
|
+
preserved: {
|
|
1823
|
+
releases: record.currentRelease?.id ? path4.join(unregister.directories.releases, record.currentRelease.id) : unregister.directories.releases,
|
|
1824
|
+
data: unregister.directories.data
|
|
1825
|
+
},
|
|
1826
|
+
deleteAfter: record.deleteAfter ?? null,
|
|
1827
|
+
container: { name: unregister.container.name, running: false, removed: true },
|
|
1828
|
+
route: route ?? { ...unregister.route, removed: true }
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
function createDeleteResult(request, deletion, removals) {
|
|
1832
|
+
const capsuleRemoved = removals.capsuleDirectory.removed;
|
|
1833
|
+
return {
|
|
1834
|
+
deleted: true,
|
|
1835
|
+
idempotent: removals.idempotent,
|
|
1836
|
+
capsule: {
|
|
1837
|
+
subname: request.capsule.subname,
|
|
1838
|
+
domain: request.host.domain,
|
|
1839
|
+
hostedUrl: deletion.hostedUrl,
|
|
1840
|
+
remoteCapsuleId: deletion.remoteCapsuleId
|
|
1841
|
+
},
|
|
1842
|
+
registryRecord: {
|
|
1843
|
+
path: deletion.registryRecord,
|
|
1844
|
+
removed: removals.registryRecord.removed,
|
|
1845
|
+
alreadyAbsent: !removals.registryRecord.removed
|
|
1846
|
+
},
|
|
1847
|
+
directories: {
|
|
1848
|
+
capsule: {
|
|
1849
|
+
path: deletion.directories.capsule,
|
|
1850
|
+
removed: capsuleRemoved,
|
|
1851
|
+
alreadyAbsent: !capsuleRemoved
|
|
1852
|
+
},
|
|
1853
|
+
releases: {
|
|
1854
|
+
path: deletion.directories.releases,
|
|
1855
|
+
removed: capsuleRemoved,
|
|
1856
|
+
alreadyAbsent: !capsuleRemoved
|
|
1857
|
+
},
|
|
1858
|
+
data: {
|
|
1859
|
+
path: deletion.directories.data,
|
|
1860
|
+
removed: capsuleRemoved,
|
|
1861
|
+
alreadyAbsent: !capsuleRemoved
|
|
1862
|
+
}
|
|
1863
|
+
},
|
|
1864
|
+
route: {
|
|
1865
|
+
path: deletion.route.routeFile,
|
|
1866
|
+
removed: removals.route.removed,
|
|
1867
|
+
alreadyAbsent: !removals.route.removed
|
|
1868
|
+
}
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
function canonicalReleasePaths(request) {
|
|
1872
|
+
const capsule = path4.join(
|
|
1873
|
+
request.host.remoteRoot,
|
|
1874
|
+
"hosts",
|
|
1875
|
+
request.host.domain,
|
|
1876
|
+
"capsules",
|
|
1877
|
+
request.capsule.subname
|
|
1878
|
+
);
|
|
1879
|
+
const releases = path4.join(capsule, "releases");
|
|
1880
|
+
return {
|
|
1881
|
+
capsule,
|
|
1882
|
+
releases,
|
|
1883
|
+
release: request.release?.id ? path4.join(releases, request.release.id) : null,
|
|
1884
|
+
data: path4.join(capsule, "data"),
|
|
1885
|
+
logs: path4.join(capsule, "logs"),
|
|
1886
|
+
currentLink: path4.join(capsule, "current")
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
function canonicalRollbackPaths(request, releaseId) {
|
|
1890
|
+
const paths = canonicalReleasePaths({ ...request, release: { id: releaseId, remoteArchive: "", files: [] } });
|
|
1891
|
+
return {
|
|
1892
|
+
...paths,
|
|
1893
|
+
release: path4.join(paths.releases, releaseId)
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
function normaliseLifecycle(request, registryRecord = null) {
|
|
1897
|
+
const provided = request.lifecycle ?? {};
|
|
1898
|
+
const paths = canonicalReleasePaths(request);
|
|
1899
|
+
const subname = request.capsule.subname;
|
|
1900
|
+
const domain = request.host.domain;
|
|
1901
|
+
const hostedUrl = typeof provided.hostedUrl === "string" ? provided.hostedUrl : request.release?.hostedUrl ?? `${request.host.scheme ?? "https"}://${subname}.${domain}`;
|
|
1902
|
+
const remoteCapsuleId = typeof provided.remoteCapsuleId === "string" ? provided.remoteCapsuleId : `${domain}/${subname}`;
|
|
1903
|
+
const containerName = provided.container?.name ?? createHostedContainerName(domain, subname);
|
|
1904
|
+
const routeFile = provided.routes?.running?.routeFile ?? path4.join(request.host.remoteRoot, "caddy", "hosts", domain, `${subname}.caddy`);
|
|
1905
|
+
const currentLink = typeof provided.currentLink === "string" ? provided.currentLink : paths.currentLink;
|
|
1906
|
+
const routeAccessLog = provided.routes;
|
|
1907
|
+
const accessLog = routeAccessLog?.accessLog ?? provided.accessLog ?? defaultCapsuleHttpLogPath(request.host.remoteRoot, domain, subname);
|
|
1908
|
+
const sealedServerEnvPrivateKey = releaseSealedServerEnvPrivateKeyMount(registryRecord, paths);
|
|
1909
|
+
const authoritativeBaseImage = request.release?.baseImage ?? registryRecord?.baseImage ?? null;
|
|
1910
|
+
const updatePolicyMode = normaliseBaseImageUpdatePolicy(
|
|
1911
|
+
authoritativeBaseImage?.updatePolicy ?? provided.container?.baseImage?.updatePolicy
|
|
1912
|
+
);
|
|
1913
|
+
const baseImage = {
|
|
1914
|
+
...baseImageMetadata(updatePolicyMode),
|
|
1915
|
+
name: authoritativeBaseImage?.name ?? provided.container?.baseImage?.name ?? SPORADES_BASE_IMAGE.name,
|
|
1916
|
+
image: authoritativeBaseImage?.image ?? provided.container?.baseImage?.image ?? provided.container?.image ?? hostHelperConfig.hostedCapsule.dockerImage,
|
|
1917
|
+
version: authoritativeBaseImage?.version ?? provided.container?.baseImage?.version ?? SPORADES_BASE_IMAGE.version
|
|
1918
|
+
};
|
|
1919
|
+
const defaultMounts = {
|
|
1920
|
+
files: [
|
|
1921
|
+
{ host: path4.join(currentLink, "server.mjs"), container: "/app/server.mjs", mode: "ro" },
|
|
1922
|
+
{ host: path4.join(currentLink, "client.js"), container: "/app/client.js", mode: "ro" },
|
|
1923
|
+
{ host: path4.join(currentLink, "index.html"), container: "/app/index.html", mode: "ro" },
|
|
1924
|
+
{ host: path4.join(currentLink, "sporades.json"), container: "/app/sporades.json", mode: "ro" },
|
|
1925
|
+
{ host: path4.join(currentLink, ".env.sporades.server"), container: "/app/.env.sporades.server", mode: "ro", optional: true },
|
|
1926
|
+
{
|
|
1927
|
+
host: path4.join(currentLink, ".sporades", "sealed-server-env", "server-env.sealed.json"),
|
|
1928
|
+
container: "/app/.sporades/sealed-server-env/server-env.sealed.json",
|
|
1929
|
+
mode: "ro",
|
|
1930
|
+
optional: true
|
|
1931
|
+
},
|
|
1932
|
+
{
|
|
1933
|
+
host: sealedServerEnvPrivateKey.host,
|
|
1934
|
+
container: "/app/.sporades/sealed-server-env/server-env.private.pem",
|
|
1935
|
+
mode: "ro",
|
|
1936
|
+
optional: !sealedServerEnvPrivateKey.fingerprint,
|
|
1937
|
+
fingerprint: sealedServerEnvPrivateKey.fingerprint
|
|
1938
|
+
}
|
|
1939
|
+
],
|
|
1940
|
+
data: { host: paths.data, container: "/app/data", mode: "rw" }
|
|
1941
|
+
};
|
|
1942
|
+
const fileMounts = authoritativeSealedServerEnvPrivateKeyMount(
|
|
1943
|
+
provided.mounts?.files ?? defaultMounts.files,
|
|
1944
|
+
sealedServerEnvPrivateKey
|
|
1945
|
+
);
|
|
1946
|
+
return {
|
|
1947
|
+
subname,
|
|
1948
|
+
domain,
|
|
1949
|
+
hostedUrl,
|
|
1950
|
+
remoteCapsuleId,
|
|
1951
|
+
currentLink,
|
|
1952
|
+
directories: {
|
|
1953
|
+
capsule: paths.capsule,
|
|
1954
|
+
releases: paths.releases,
|
|
1955
|
+
data: paths.data,
|
|
1956
|
+
logs: paths.logs
|
|
1957
|
+
},
|
|
1958
|
+
remoteRoot: request.host.remoteRoot,
|
|
1959
|
+
mounts: {
|
|
1960
|
+
...provided.mounts,
|
|
1961
|
+
files: fileMounts,
|
|
1962
|
+
data: provided.mounts?.data ?? defaultMounts.data
|
|
1963
|
+
},
|
|
1964
|
+
container: {
|
|
1965
|
+
name: containerName,
|
|
1966
|
+
network: provided.container?.network ?? hostHelperConfig.hostedCapsule.dockerNetwork,
|
|
1967
|
+
image: provided.container?.image ?? baseImage.image,
|
|
1968
|
+
user: provided.container?.user ?? baseImageRuntimeUser(),
|
|
1969
|
+
baseImage,
|
|
1970
|
+
graceCheckMs: provided.container?.graceCheckMs ?? hostHelperConfig.hostedCapsule.graceCheckMs,
|
|
1971
|
+
labels: {
|
|
1972
|
+
...provided.container?.labels ?? {},
|
|
1973
|
+
"com.sporades.managed": "true",
|
|
1974
|
+
"com.sporades.hosted-domain": domain,
|
|
1975
|
+
"com.sporades.capsule-subname": subname,
|
|
1976
|
+
"com.sporades.capsule-id": remoteCapsuleId,
|
|
1977
|
+
...baseImageLabels(updatePolicyMode)
|
|
1978
|
+
}
|
|
1979
|
+
},
|
|
1980
|
+
routes: {
|
|
1981
|
+
running: withRouteAccessLog(
|
|
1982
|
+
provided.routes?.running ?? {
|
|
1983
|
+
hostname: `${subname}.${domain}`,
|
|
1984
|
+
target: "container",
|
|
1985
|
+
containerName,
|
|
1986
|
+
port: 4e3,
|
|
1987
|
+
routeFile
|
|
1988
|
+
},
|
|
1989
|
+
accessLog
|
|
1990
|
+
),
|
|
1991
|
+
unavailable: withRouteAccessLog(
|
|
1992
|
+
provided.routes?.unavailable ?? {
|
|
1993
|
+
hostname: `${subname}.${domain}`,
|
|
1994
|
+
target: "hosted-capsule-unavailable",
|
|
1995
|
+
statusCode: 503,
|
|
1996
|
+
routeFile
|
|
1997
|
+
},
|
|
1998
|
+
accessLog
|
|
1999
|
+
)
|
|
2000
|
+
}
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
function authoritativeSealedServerEnvPrivateKeyMount(fileMounts, sealedServerEnvPrivateKey) {
|
|
2004
|
+
const privateKeyContainerPath = "/app/.sporades/sealed-server-env/server-env.private.pem";
|
|
2005
|
+
let replaced = false;
|
|
2006
|
+
const next = fileMounts.map((mount) => {
|
|
2007
|
+
if (mount.container !== privateKeyContainerPath) {
|
|
2008
|
+
return mount;
|
|
2009
|
+
}
|
|
2010
|
+
replaced = true;
|
|
2011
|
+
return {
|
|
2012
|
+
...mount,
|
|
2013
|
+
host: sealedServerEnvPrivateKey.host,
|
|
2014
|
+
mode: mount.mode ?? "ro",
|
|
2015
|
+
optional: !sealedServerEnvPrivateKey.fingerprint,
|
|
2016
|
+
fingerprint: sealedServerEnvPrivateKey.fingerprint
|
|
2017
|
+
};
|
|
2018
|
+
});
|
|
2019
|
+
if (!replaced && sealedServerEnvPrivateKey.fingerprint) {
|
|
2020
|
+
next.push({
|
|
2021
|
+
host: sealedServerEnvPrivateKey.host,
|
|
2022
|
+
container: privateKeyContainerPath,
|
|
2023
|
+
mode: "ro",
|
|
2024
|
+
optional: false,
|
|
2025
|
+
fingerprint: sealedServerEnvPrivateKey.fingerprint
|
|
2026
|
+
});
|
|
2027
|
+
}
|
|
2028
|
+
return next;
|
|
2029
|
+
}
|
|
2030
|
+
function withRouteAccessLog(route, accessLog) {
|
|
2031
|
+
if (route.log === null) {
|
|
2032
|
+
return route;
|
|
2033
|
+
}
|
|
2034
|
+
return {
|
|
2035
|
+
...route,
|
|
2036
|
+
log: route.log ?? { file: accessLog }
|
|
2037
|
+
};
|
|
2038
|
+
}
|
|
2039
|
+
function normaliseStats(request) {
|
|
2040
|
+
const provided = request.stats ?? {};
|
|
2041
|
+
const subname = request.capsule.subname;
|
|
2042
|
+
const domain = request.host.domain;
|
|
2043
|
+
const hostedUrl = provided.hostedUrl ?? `${request.host.scheme ?? "https"}://${subname}.${domain}`;
|
|
2044
|
+
const remoteCapsuleId = provided.remoteCapsuleId ?? `${domain}/${subname}`;
|
|
2045
|
+
return {
|
|
2046
|
+
hostedUrl,
|
|
2047
|
+
remoteCapsuleId,
|
|
2048
|
+
container: {
|
|
2049
|
+
name: provided.container?.name ?? createHostedContainerName(domain, subname)
|
|
2050
|
+
}
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
function normaliseHealth(request, record = null) {
|
|
2054
|
+
const provided = request.health ?? {};
|
|
2055
|
+
const subname = request.capsule.subname;
|
|
2056
|
+
const domain = request.host.domain;
|
|
2057
|
+
const hostedUrl = record?.hostedUrl ?? `${request.host.scheme ?? "https"}://${subname}.${domain}`;
|
|
2058
|
+
const remoteCapsuleId = record?.remoteCapsuleId ?? `${domain}/${subname}`;
|
|
2059
|
+
return {
|
|
2060
|
+
hostedUrl,
|
|
2061
|
+
remoteCapsuleId,
|
|
2062
|
+
runtimeHealthUrl: `${hostedUrl}${CAPSULE_RUNTIME_HEALTH_PATH}`,
|
|
2063
|
+
container: {
|
|
2064
|
+
name: provided.container?.name ?? createHostedContainerName(domain, subname)
|
|
2065
|
+
}
|
|
2066
|
+
};
|
|
2067
|
+
}
|
|
2068
|
+
async function ensureRuntimeProbeCredential(request) {
|
|
2069
|
+
let probe = null;
|
|
2070
|
+
await mutateRegistryRecord(request, (record) => {
|
|
2071
|
+
probe = readRuntimeProbeCredential(record) ?? {
|
|
2072
|
+
header: RUNTIME_PROBE_HEADER,
|
|
2073
|
+
token: randomBytes(32).toString("hex"),
|
|
2074
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2075
|
+
};
|
|
2076
|
+
return { ...record, runtimeProbe: probe };
|
|
2077
|
+
});
|
|
2078
|
+
return probe;
|
|
2079
|
+
}
|
|
2080
|
+
function readRuntimeProbeCredential(record) {
|
|
2081
|
+
const header = record?.runtimeProbe?.header;
|
|
2082
|
+
const token = record?.runtimeProbe?.token;
|
|
2083
|
+
if (header !== RUNTIME_PROBE_HEADER || typeof token !== "string" || token.length === 0) {
|
|
2084
|
+
return null;
|
|
2085
|
+
}
|
|
2086
|
+
return { header, token };
|
|
2087
|
+
}
|
|
2088
|
+
function normaliseRuntimeHealthBody(body) {
|
|
2089
|
+
const checks = body?.data?.checks;
|
|
2090
|
+
const sqlite = checks?.sqlite;
|
|
2091
|
+
const fileStorage = checks?.fileStorage;
|
|
2092
|
+
const ready = body?.data?.runtime?.ready;
|
|
2093
|
+
const valid = typeof body?.ok === "boolean" && typeof ready === "boolean" && typeof sqlite?.ok === "boolean" && typeof fileStorage?.ok === "boolean";
|
|
2094
|
+
const safe = {
|
|
2095
|
+
ready: ready === true,
|
|
2096
|
+
checks: {
|
|
2097
|
+
sqlite: { ok: sqlite?.ok === true },
|
|
2098
|
+
fileStorage: { ok: fileStorage?.ok === true }
|
|
2099
|
+
}
|
|
2100
|
+
};
|
|
2101
|
+
return { valid, ready: ready === true, checks: safe.checks, safe };
|
|
2102
|
+
}
|
|
2103
|
+
function healthFailure(request, health, failure, message, hint, extra = {}) {
|
|
2104
|
+
return {
|
|
2105
|
+
ok: false,
|
|
2106
|
+
data: {
|
|
2107
|
+
capsule: {
|
|
2108
|
+
subname: request.capsule.subname,
|
|
2109
|
+
domain: request.host.domain,
|
|
2110
|
+
hostedUrl: health.hostedUrl,
|
|
2111
|
+
remoteCapsuleId: health.remoteCapsuleId
|
|
2112
|
+
},
|
|
2113
|
+
route: { url: health.runtimeHealthUrl },
|
|
2114
|
+
container: { name: health.container.name },
|
|
2115
|
+
failure,
|
|
2116
|
+
...extra
|
|
2117
|
+
},
|
|
2118
|
+
error: { message, hint }
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
function unregisteredHealthFailure(request, health) {
|
|
2122
|
+
return healthFailure(
|
|
2123
|
+
request,
|
|
2124
|
+
health,
|
|
2125
|
+
"unregistered-capsule",
|
|
2126
|
+
"Hosted Capsule is not registered.",
|
|
2127
|
+
`Run \`sporades host register ${request.capsule.subname} --host ${request.host.alias}\` before checking runtime health.`
|
|
2128
|
+
);
|
|
2129
|
+
}
|
|
2130
|
+
function publicRouteData(route) {
|
|
2131
|
+
const { runtimeProbe, ...safeRoute } = route;
|
|
2132
|
+
return safeRoute;
|
|
2133
|
+
}
|
|
2134
|
+
async function readHostDiskStats(targetPath) {
|
|
2135
|
+
let stats;
|
|
2136
|
+
try {
|
|
2137
|
+
stats = await statfs(targetPath);
|
|
2138
|
+
} catch {
|
|
2139
|
+
throw helperError(
|
|
2140
|
+
"Failed to read Host server disk stats.",
|
|
2141
|
+
`Check that ${targetPath} exists and is readable by the Host helper, then retry \`sporades host stats --host <alias>\`.`
|
|
2142
|
+
);
|
|
2143
|
+
}
|
|
2144
|
+
const blockSize = Number(stats.bsize);
|
|
2145
|
+
const totalBytes = Number(stats.blocks) * blockSize;
|
|
2146
|
+
const freeBytes = Number(stats.bfree) * blockSize;
|
|
2147
|
+
const availableBytes = Number(stats.bavail) * blockSize;
|
|
2148
|
+
const usedBytes = Math.max(0, totalBytes - freeBytes);
|
|
2149
|
+
return {
|
|
2150
|
+
path: targetPath,
|
|
2151
|
+
totalBytes,
|
|
2152
|
+
usedBytes,
|
|
2153
|
+
availableBytes,
|
|
2154
|
+
usedPercent: percentage(usedBytes, totalBytes)
|
|
2155
|
+
};
|
|
2156
|
+
}
|
|
2157
|
+
function readHostMemoryStats() {
|
|
2158
|
+
const totalBytes = totalmem();
|
|
2159
|
+
const availableBytes = freemem();
|
|
2160
|
+
const usedBytes = Math.max(0, totalBytes - availableBytes);
|
|
2161
|
+
return {
|
|
2162
|
+
totalBytes,
|
|
2163
|
+
usedBytes,
|
|
2164
|
+
availableBytes,
|
|
2165
|
+
usedPercent: percentage(usedBytes, totalBytes)
|
|
2166
|
+
};
|
|
2167
|
+
}
|
|
2168
|
+
function readHostLoadStats() {
|
|
2169
|
+
const [oneMinute, fiveMinutes, fifteenMinutes] = loadavg();
|
|
2170
|
+
return { oneMinute, fiveMinutes, fifteenMinutes };
|
|
2171
|
+
}
|
|
2172
|
+
function checkDockerAvailable() {
|
|
2173
|
+
return runDocker(["version", "--format", "{{.Server.Version}}"]).ok;
|
|
2174
|
+
}
|
|
2175
|
+
function checkCaddyAvailable() {
|
|
2176
|
+
const result = spawnSync2("caddy", ["version"], { encoding: "utf8" });
|
|
2177
|
+
return !result.error && result.status === 0;
|
|
2178
|
+
}
|
|
2179
|
+
function countHostedCapsules(records, dockerStates) {
|
|
2180
|
+
let running = 0;
|
|
2181
|
+
let stopped = 0;
|
|
2182
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
2183
|
+
const docker = dockerStates[index] ?? null;
|
|
2184
|
+
if (docker?.running === true) {
|
|
2185
|
+
running += 1;
|
|
2186
|
+
continue;
|
|
2187
|
+
}
|
|
2188
|
+
if (docker?.running === false || records[index].status === "stopped") {
|
|
2189
|
+
stopped += 1;
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
return {
|
|
2193
|
+
total: records.length,
|
|
2194
|
+
registered: records.filter((record) => (record.status ?? "registered") === "registered").length,
|
|
2195
|
+
running,
|
|
2196
|
+
stopped,
|
|
2197
|
+
unavailable: records.length - running
|
|
2198
|
+
};
|
|
2199
|
+
}
|
|
2200
|
+
function readCapsuleLifecycle(_request, registryRecord, containerName, running) {
|
|
2201
|
+
const inspected = inspectContainerLifecycle(containerName);
|
|
2202
|
+
return {
|
|
2203
|
+
registered: true,
|
|
2204
|
+
registryStatus: registryRecord.status ?? "registered",
|
|
2205
|
+
running,
|
|
2206
|
+
startedAt: inspected.startedAt,
|
|
2207
|
+
uptimeSeconds: inspected.uptimeSeconds,
|
|
2208
|
+
restartCount: inspected.restartCount,
|
|
2209
|
+
currentReleaseId: registryRecord.currentRelease?.id ?? null,
|
|
2210
|
+
routeTarget: registryRecord.route?.target ?? (running ? "container" : "hosted-capsule-unavailable")
|
|
2211
|
+
};
|
|
2212
|
+
}
|
|
2213
|
+
function inspectContainerLifecycle(containerName) {
|
|
2214
|
+
const result = runDocker(["inspect", "--format", "{{json .}}", containerName]);
|
|
2215
|
+
if (!result.ok) {
|
|
2216
|
+
return { startedAt: null, uptimeSeconds: null, restartCount: null };
|
|
2217
|
+
}
|
|
2218
|
+
let raw;
|
|
2219
|
+
try {
|
|
2220
|
+
raw = JSON.parse(result.stdout);
|
|
2221
|
+
} catch {
|
|
2222
|
+
return { startedAt: null, uptimeSeconds: null, restartCount: null };
|
|
2223
|
+
}
|
|
2224
|
+
const startedAt = typeof raw.State?.StartedAt === "string" && raw.State.StartedAt !== "0001-01-01T00:00:00Z" ? raw.State.StartedAt : null;
|
|
2225
|
+
return {
|
|
2226
|
+
startedAt,
|
|
2227
|
+
uptimeSeconds: startedAt ? Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1e3)) : null,
|
|
2228
|
+
restartCount: Number.isFinite(raw.RestartCount) ? raw.RestartCount : null
|
|
2229
|
+
};
|
|
2230
|
+
}
|
|
2231
|
+
async function readCapsuleRegistryRecords(request) {
|
|
2232
|
+
const registryDirectory = path4.join(request.host.remoteRoot, "hosts", request.host.domain, "registry", "capsules");
|
|
2233
|
+
let entries;
|
|
2234
|
+
try {
|
|
2235
|
+
entries = await readdir2(registryDirectory, { withFileTypes: true });
|
|
2236
|
+
} catch (error) {
|
|
2237
|
+
if (errorDetails(error).code === "ENOENT") {
|
|
2238
|
+
return [];
|
|
2239
|
+
}
|
|
2240
|
+
throw error;
|
|
2241
|
+
}
|
|
2242
|
+
const records = [];
|
|
2243
|
+
const files = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => entry.name).sort();
|
|
2244
|
+
for (const file of files) {
|
|
2245
|
+
const recordPath = path4.join(registryDirectory, file);
|
|
2246
|
+
let record;
|
|
2247
|
+
try {
|
|
2248
|
+
record = JSON.parse(await readFile2(recordPath, "utf8"));
|
|
2249
|
+
} catch (error) {
|
|
2250
|
+
if (error instanceof SyntaxError) {
|
|
2251
|
+
throw helperError(
|
|
2252
|
+
"Hosted Capsule registry record is invalid.",
|
|
2253
|
+
`Repair the Host server registry record at ${recordPath}, then retry \`${hostRegistryRetryCommand(request)}\`.`
|
|
2254
|
+
);
|
|
2255
|
+
}
|
|
2256
|
+
throw error;
|
|
2257
|
+
}
|
|
2258
|
+
validateListRegistryRecord(request, record, recordPath);
|
|
2259
|
+
records.push(record);
|
|
2260
|
+
}
|
|
2261
|
+
return records;
|
|
2262
|
+
}
|
|
2263
|
+
function lookupCapsuleDockerState(request, record) {
|
|
2264
|
+
const subname = record.subname;
|
|
2265
|
+
const containerName = createHostedContainerName(request.host.domain, subname);
|
|
2266
|
+
const result = runDocker([
|
|
2267
|
+
"ps",
|
|
2268
|
+
"-a",
|
|
2269
|
+
"--filter",
|
|
2270
|
+
"label=com.sporades.managed=true",
|
|
2271
|
+
"--filter",
|
|
2272
|
+
`label=com.sporades.hosted-domain=${request.host.domain}`,
|
|
2273
|
+
"--filter",
|
|
2274
|
+
`label=com.sporades.capsule-subname=${subname}`,
|
|
2275
|
+
"--format",
|
|
2276
|
+
"json"
|
|
2277
|
+
]);
|
|
2278
|
+
if (!result.ok) {
|
|
2279
|
+
return null;
|
|
2280
|
+
}
|
|
2281
|
+
const containers = parseDockerPsJsonLines(result.stdout);
|
|
2282
|
+
const remoteCapsuleId = record.remoteCapsuleId ?? `${request.host.domain}/${subname}`;
|
|
2283
|
+
const match = containers.find((container) => dockerPsContainerMatches(container, containerName, remoteCapsuleId, subname));
|
|
2284
|
+
return match ? normaliseDockerPsContainer(match, containerName) : null;
|
|
2285
|
+
}
|
|
2286
|
+
function parseDockerPsJsonLines(output) {
|
|
2287
|
+
return String(output ?? "").split("\n").map((line) => line.trim()).filter(Boolean).map((line) => {
|
|
2288
|
+
try {
|
|
2289
|
+
return JSON.parse(line);
|
|
2290
|
+
} catch {
|
|
2291
|
+
return null;
|
|
2292
|
+
}
|
|
2293
|
+
}).filter(Boolean);
|
|
2294
|
+
}
|
|
2295
|
+
function dockerPsContainerMatches(container, containerName, remoteCapsuleId, subname) {
|
|
2296
|
+
const names = String(container.Names ?? container.Name ?? "");
|
|
2297
|
+
const labels = parseDockerLabels(container.Labels);
|
|
2298
|
+
return names.split(",").map((name) => name.trim()).includes(containerName) || labels["com.sporades.capsule-id"] === remoteCapsuleId || labels["com.sporades.capsule-subname"] === subname;
|
|
2299
|
+
}
|
|
2300
|
+
function parseDockerLabels(value) {
|
|
2301
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
2302
|
+
return Object.fromEntries(Object.entries(value).map(([key, labelValue]) => [key, String(labelValue)]));
|
|
2303
|
+
}
|
|
2304
|
+
const labels = {};
|
|
2305
|
+
for (const label of String(value ?? "").split(",")) {
|
|
2306
|
+
const [key, ...rest] = label.split("=");
|
|
2307
|
+
if (key && rest.length > 0) {
|
|
2308
|
+
labels[key.trim()] = rest.join("=").trim();
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
return labels;
|
|
2312
|
+
}
|
|
2313
|
+
function normaliseDockerPsContainer(container, fallbackContainerName) {
|
|
2314
|
+
const state = String(container.State ?? container.state ?? "").toLowerCase();
|
|
2315
|
+
const containerName = String(container.Names ?? container.Name ?? fallbackContainerName).split(",")[0].trim();
|
|
2316
|
+
const status = String(container.Status ?? container.status ?? "");
|
|
2317
|
+
const labels = parseDockerLabels(container.Labels);
|
|
2318
|
+
const normalised = {
|
|
2319
|
+
containerId: String(container.ID ?? container.Id ?? container.id ?? ""),
|
|
2320
|
+
containerName,
|
|
2321
|
+
image: String(container.Image ?? container.image ?? ""),
|
|
2322
|
+
state: state || "unknown",
|
|
2323
|
+
status,
|
|
2324
|
+
running: state === "running"
|
|
2325
|
+
};
|
|
2326
|
+
const baseImage = normaliseDockerBaseImage(labels);
|
|
2327
|
+
if (baseImage) {
|
|
2328
|
+
normalised.baseImage = baseImage;
|
|
2329
|
+
}
|
|
2330
|
+
return normalised;
|
|
2331
|
+
}
|
|
2332
|
+
function normaliseDockerBaseImage(labels) {
|
|
2333
|
+
if (!labels["com.sporades.base-image.name"] && !labels["com.sporades.base-image.version"]) {
|
|
2334
|
+
return null;
|
|
2335
|
+
}
|
|
2336
|
+
return {
|
|
2337
|
+
name: labels["com.sporades.base-image.name"] ?? SPORADES_BASE_IMAGE.name,
|
|
2338
|
+
version: labels["com.sporades.base-image.version"] ?? "unknown",
|
|
2339
|
+
updatePolicy: {
|
|
2340
|
+
mode: normaliseBaseImageUpdatePolicy(labels["com.sporades.base-image.update-policy"])
|
|
2341
|
+
}
|
|
2342
|
+
};
|
|
2343
|
+
}
|
|
2344
|
+
function normaliseRecordBaseImage(record, docker = null) {
|
|
2345
|
+
const provided = record.baseImage ?? {};
|
|
2346
|
+
const dockerBaseImage = docker?.baseImage ?? null;
|
|
2347
|
+
const hasKnownBaseImage = Boolean(record.baseImage || dockerBaseImage);
|
|
2348
|
+
const mode = normaliseBaseImageUpdatePolicy(provided.updatePolicy ?? dockerBaseImage?.updatePolicy);
|
|
2349
|
+
const metadata = baseImageMetadata(mode);
|
|
2350
|
+
if (!hasKnownBaseImage) {
|
|
2351
|
+
return {
|
|
2352
|
+
...metadata,
|
|
2353
|
+
name: "unknown",
|
|
2354
|
+
image: docker?.image ?? "unknown",
|
|
2355
|
+
version: "unknown"
|
|
2356
|
+
};
|
|
2357
|
+
}
|
|
2358
|
+
return {
|
|
2359
|
+
...metadata,
|
|
2360
|
+
image: provided.image ?? docker?.image ?? SPORADES_BASE_IMAGE.image,
|
|
2361
|
+
name: provided.name ?? dockerBaseImage?.name ?? SPORADES_BASE_IMAGE.name,
|
|
2362
|
+
version: provided.version ?? dockerBaseImage?.version ?? SPORADES_BASE_IMAGE.version
|
|
2363
|
+
};
|
|
2364
|
+
}
|
|
2365
|
+
function normaliseProvidedBaseImage(value) {
|
|
2366
|
+
const provided = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
2367
|
+
const updatePolicy = provided.updatePolicy && typeof provided.updatePolicy === "object" && "mode" in provided.updatePolicy ? provided.updatePolicy.mode : provided.updatePolicy;
|
|
2368
|
+
const mode = normaliseBaseImageUpdatePolicy(updatePolicy ?? "pinned");
|
|
2369
|
+
return {
|
|
2370
|
+
...baseImageMetadata(mode),
|
|
2371
|
+
image: provided.image ?? SPORADES_BASE_IMAGE.image,
|
|
2372
|
+
name: provided.name ?? SPORADES_BASE_IMAGE.name,
|
|
2373
|
+
version: provided.version ?? SPORADES_BASE_IMAGE.version
|
|
2374
|
+
};
|
|
2375
|
+
}
|
|
2376
|
+
function normaliseRegistration(request) {
|
|
2377
|
+
const subname = request.capsule.subname;
|
|
2378
|
+
const domain = request.host.domain;
|
|
2379
|
+
const remoteRoot = request.host.remoteRoot;
|
|
2380
|
+
const scheme = request.host.scheme ?? "https";
|
|
2381
|
+
const hostedUrl = `${scheme}://${subname}.${domain}`;
|
|
2382
|
+
const remoteCapsuleId = `${domain}/${subname}`;
|
|
2383
|
+
const capsuleDirectory = path4.join(remoteRoot, "hosts", domain, "capsules", subname);
|
|
2384
|
+
const routeFile = path4.join(remoteRoot, "caddy", "hosts", domain, `${subname}.caddy`);
|
|
2385
|
+
const routeTls = normaliseRegistrationTls(request);
|
|
2386
|
+
const accessLog = request.registration?.route?.log?.file ?? defaultCapsuleHttpLogPath(remoteRoot, domain, subname);
|
|
2387
|
+
const route = {
|
|
2388
|
+
hostname: `${subname}.${domain}`,
|
|
2389
|
+
target: "hosted-capsule-unavailable",
|
|
2390
|
+
statusCode: 503,
|
|
2391
|
+
routeFile,
|
|
2392
|
+
tls: routeTls,
|
|
2393
|
+
log: { file: accessLog }
|
|
2394
|
+
};
|
|
2395
|
+
return {
|
|
2396
|
+
subname,
|
|
2397
|
+
domain,
|
|
2398
|
+
remoteRoot,
|
|
2399
|
+
hostedUrl,
|
|
2400
|
+
remoteCapsuleId,
|
|
2401
|
+
registryRecord: path4.join(remoteRoot, "hosts", domain, "registry", "capsules", `${subname}.json`),
|
|
2402
|
+
directories: {
|
|
2403
|
+
capsule: capsuleDirectory,
|
|
2404
|
+
releases: path4.join(capsuleDirectory, "releases"),
|
|
2405
|
+
data: path4.join(capsuleDirectory, "data"),
|
|
2406
|
+
logs: path4.join(capsuleDirectory, "logs")
|
|
2407
|
+
},
|
|
2408
|
+
baseImage: normaliseProvidedBaseImage(request.registration?.baseImage),
|
|
2409
|
+
route,
|
|
2410
|
+
lifecycle: {
|
|
2411
|
+
remoteRoot,
|
|
2412
|
+
routes: { unavailable: route }
|
|
2413
|
+
}
|
|
2414
|
+
};
|
|
2415
|
+
}
|
|
2416
|
+
function normaliseUnregister(request) {
|
|
2417
|
+
const provided = request.unregister ?? {};
|
|
2418
|
+
const subname = request.capsule.subname;
|
|
2419
|
+
const domain = request.host.domain;
|
|
2420
|
+
const remoteRoot = request.host.remoteRoot;
|
|
2421
|
+
const scheme = request.host.scheme ?? "https";
|
|
2422
|
+
const hostedUrl = `${scheme}://${subname}.${domain}`;
|
|
2423
|
+
const remoteCapsuleId = `${domain}/${subname}`;
|
|
2424
|
+
const capsuleDirectory = path4.join(remoteRoot, "hosts", domain, "capsules", subname);
|
|
2425
|
+
const routeFile = path4.join(remoteRoot, "caddy", "hosts", domain, `${subname}.caddy`);
|
|
2426
|
+
const containerName = createHostedContainerName(domain, subname);
|
|
2427
|
+
return {
|
|
2428
|
+
subname,
|
|
2429
|
+
domain,
|
|
2430
|
+
hostedUrl,
|
|
2431
|
+
remoteCapsuleId,
|
|
2432
|
+
registryRecord: path4.join(remoteRoot, "hosts", domain, "registry", "capsules", `${subname}.json`),
|
|
2433
|
+
directories: {
|
|
2434
|
+
capsule: capsuleDirectory,
|
|
2435
|
+
releases: path4.join(capsuleDirectory, "releases"),
|
|
2436
|
+
data: path4.join(capsuleDirectory, "data")
|
|
2437
|
+
},
|
|
2438
|
+
container: {
|
|
2439
|
+
name: containerName
|
|
2440
|
+
},
|
|
2441
|
+
route: {
|
|
2442
|
+
hostname: `${subname}.${domain}`,
|
|
2443
|
+
target: "removed",
|
|
2444
|
+
routeFile
|
|
2445
|
+
},
|
|
2446
|
+
lifecycle: {
|
|
2447
|
+
remoteRoot
|
|
2448
|
+
},
|
|
2449
|
+
provided
|
|
2450
|
+
};
|
|
2451
|
+
}
|
|
2452
|
+
function normaliseDeletion(request) {
|
|
2453
|
+
const subname = request.capsule.subname;
|
|
2454
|
+
const domain = request.host.domain;
|
|
2455
|
+
const remoteRoot = request.host.remoteRoot;
|
|
2456
|
+
const scheme = request.host.scheme ?? "https";
|
|
2457
|
+
const capsuleDirectory = path4.join(remoteRoot, "hosts", domain, "capsules", subname);
|
|
2458
|
+
return {
|
|
2459
|
+
subname,
|
|
2460
|
+
domain,
|
|
2461
|
+
hostedUrl: `${scheme}://${subname}.${domain}`,
|
|
2462
|
+
remoteCapsuleId: `${domain}/${subname}`,
|
|
2463
|
+
registryRecord: path4.join(remoteRoot, "hosts", domain, "registry", "capsules", `${subname}.json`),
|
|
2464
|
+
directories: {
|
|
2465
|
+
capsule: capsuleDirectory,
|
|
2466
|
+
releases: path4.join(capsuleDirectory, "releases"),
|
|
2467
|
+
data: path4.join(capsuleDirectory, "data")
|
|
2468
|
+
},
|
|
2469
|
+
route: {
|
|
2470
|
+
hostname: `${subname}.${domain}`,
|
|
2471
|
+
routeFile: path4.join(remoteRoot, "caddy", "hosts", domain, `${subname}.caddy`)
|
|
2472
|
+
},
|
|
2473
|
+
lifecycle: {
|
|
2474
|
+
remoteRoot
|
|
2475
|
+
}
|
|
2476
|
+
};
|
|
2477
|
+
}
|
|
2478
|
+
function normaliseRegistrationTls(request) {
|
|
2479
|
+
const remoteRoot = request.host.remoteRoot;
|
|
2480
|
+
const domain = request.host.domain;
|
|
2481
|
+
const tlsMode = request.registration?.bootstrap?.tls?.mode ?? request.bootstrap?.tls?.mode ?? "automatic";
|
|
2482
|
+
const tlsDirectory = path4.join(remoteRoot, "hosts", domain, "tls");
|
|
2483
|
+
return {
|
|
2484
|
+
mode: tlsMode,
|
|
2485
|
+
directory: tlsDirectory,
|
|
2486
|
+
certificate: tlsMode === "cloudflare-origin" ? path4.join(tlsDirectory, "origin.crt") : null,
|
|
2487
|
+
key: tlsMode === "cloudflare-origin" ? path4.join(tlsDirectory, "origin.key") : null
|
|
2488
|
+
};
|
|
2489
|
+
}
|
|
2490
|
+
async function ensureHostedDomainBootstrapped(request, registration) {
|
|
2491
|
+
const caddyfile = path4.join(request.host.remoteRoot, "caddy", "Caddyfile");
|
|
2492
|
+
const domainInclude = path4.join(request.host.remoteRoot, "caddy", "hosts", `${request.host.domain}.caddy`);
|
|
2493
|
+
const bootstrapped = await pathExists(caddyfile) && await pathExists(domainInclude);
|
|
2494
|
+
if (bootstrapped) {
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
const tls = registration.route.tls;
|
|
2498
|
+
const tlsHint = tls?.mode === "cloudflare-origin" ? ` after installing readable Cloudflare origin certificate and key files at ${tls.certificate} and ${tls.key}` : "";
|
|
2499
|
+
throw helperError(
|
|
2500
|
+
"Hosted domain has not been bootstrapped.",
|
|
2501
|
+
`Run \`sporades host bootstrap --host ${request.host.alias}\`${tlsHint}.`
|
|
2502
|
+
);
|
|
2503
|
+
}
|
|
2504
|
+
function createRegistrationRecord(registration, sealedServerEnv = null) {
|
|
2505
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2506
|
+
return {
|
|
2507
|
+
subname: registration.subname,
|
|
2508
|
+
domain: registration.domain,
|
|
2509
|
+
remoteCapsuleId: registration.remoteCapsuleId,
|
|
2510
|
+
hostedUrl: registration.hostedUrl,
|
|
2511
|
+
status: "registered",
|
|
2512
|
+
createdAt: now,
|
|
2513
|
+
updatedAt: now,
|
|
2514
|
+
currentRelease: null,
|
|
2515
|
+
baseImage: registration.baseImage,
|
|
2516
|
+
...sealedServerEnv ? { sealedServerEnv: { currentKeyFingerprint: sealedServerEnv.publicKeyFingerprint } } : {}
|
|
2517
|
+
};
|
|
2518
|
+
}
|
|
2519
|
+
async function ensureHostSealedEnvKeyPair(registration, existingRecord = null) {
|
|
2520
|
+
const existingFingerprint = existingRecord?.sealedServerEnv?.currentKeyFingerprint;
|
|
2521
|
+
if (existingFingerprint) {
|
|
2522
|
+
const existing = await readPublicHostSealedEnvKey(
|
|
2523
|
+
{
|
|
2524
|
+
subname: registration.subname,
|
|
2525
|
+
domain: registration.domain,
|
|
2526
|
+
sealedServerEnv: { currentKeyFingerprint: existingFingerprint }
|
|
2527
|
+
},
|
|
2528
|
+
registration.remoteRoot
|
|
2529
|
+
);
|
|
2530
|
+
if (existing) {
|
|
2531
|
+
return existing;
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
return generateHostSealedEnvKeyPair(registration.directories.data);
|
|
2535
|
+
}
|
|
2536
|
+
async function generateHostSealedEnvKeyPair(dataDirectory) {
|
|
2537
|
+
const { publicKey, privateKey } = generateKeyPairSync("rsa", {
|
|
2538
|
+
modulusLength: 2048,
|
|
2539
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
2540
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" }
|
|
2541
|
+
});
|
|
2542
|
+
const publicKeyFingerprint = fingerprintPublicKey(publicKey);
|
|
2543
|
+
const paths = hostSealedEnvKeyPaths(dataDirectory, publicKeyFingerprint);
|
|
2544
|
+
await mkdir(paths.root, { recursive: true, mode: 448 });
|
|
2545
|
+
await chmod(paths.root, 448);
|
|
2546
|
+
await mkdir(paths.keys, { recursive: true, mode: 448 });
|
|
2547
|
+
await chmod(paths.keys, 448);
|
|
2548
|
+
await writeFile(paths.privateKey, privateKey, { mode: 384 });
|
|
2549
|
+
await chmod(paths.privateKey, 384);
|
|
2550
|
+
await writeFile(paths.publicKey, publicKey, { mode: 420 });
|
|
2551
|
+
await chmod(paths.publicKey, 420);
|
|
2552
|
+
return {
|
|
2553
|
+
publicKey,
|
|
2554
|
+
publicKeyFingerprint,
|
|
2555
|
+
publicKeyPath: paths.publicKey
|
|
2556
|
+
};
|
|
2557
|
+
}
|
|
2558
|
+
async function cleanupUnreferencedHostSealedEnvKeys(dataDirectory, referencedFingerprints) {
|
|
2559
|
+
const paths = hostSealedEnvKeyPaths(dataDirectory, "placeholder");
|
|
2560
|
+
let entries;
|
|
2561
|
+
try {
|
|
2562
|
+
entries = await readdir2(paths.keys);
|
|
2563
|
+
} catch (error) {
|
|
2564
|
+
if (errorDetails(error).code === "ENOENT") {
|
|
2565
|
+
return {
|
|
2566
|
+
deletedKeyFingerprints: [],
|
|
2567
|
+
retainedKeyFingerprints: [...referencedFingerprints].sort()
|
|
2568
|
+
};
|
|
2569
|
+
}
|
|
2570
|
+
throw error;
|
|
2571
|
+
}
|
|
2572
|
+
const deleted = /* @__PURE__ */ new Set();
|
|
2573
|
+
for (const entry of entries) {
|
|
2574
|
+
const match = /^([a-f0-9]{16})\.(private|public)\.pem$/.exec(entry);
|
|
2575
|
+
if (!match) {
|
|
2576
|
+
continue;
|
|
2577
|
+
}
|
|
2578
|
+
const fingerprint = match[1];
|
|
2579
|
+
if (referencedFingerprints.has(fingerprint)) {
|
|
2580
|
+
continue;
|
|
2581
|
+
}
|
|
2582
|
+
await rm2(path4.join(paths.keys, entry), { force: true });
|
|
2583
|
+
deleted.add(fingerprint);
|
|
2584
|
+
}
|
|
2585
|
+
return {
|
|
2586
|
+
deletedKeyFingerprints: [...deleted].sort(),
|
|
2587
|
+
retainedKeyFingerprints: [...referencedFingerprints].sort()
|
|
2588
|
+
};
|
|
2589
|
+
}
|
|
2590
|
+
function referencedSealedEnvKeyFingerprints(record) {
|
|
2591
|
+
const fingerprints = /* @__PURE__ */ new Set();
|
|
2592
|
+
for (const release of normaliseReleaseHistory(record)) {
|
|
2593
|
+
const fingerprint = release?.source?.sealedServerEnv?.publicKeyFingerprint;
|
|
2594
|
+
if (typeof fingerprint === "string" && fingerprint.length > 0) {
|
|
2595
|
+
fingerprints.add(fingerprint);
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
return fingerprints;
|
|
2599
|
+
}
|
|
2600
|
+
async function readPublicHostSealedEnvKey(record, remoteRoot) {
|
|
2601
|
+
const inspected = await inspectHostSealedEnvKey(record, remoteRoot);
|
|
2602
|
+
if (!inspected?.publicKey || !inspected?.publicKeyFingerprint) {
|
|
2603
|
+
return null;
|
|
2604
|
+
}
|
|
2605
|
+
return {
|
|
2606
|
+
publicKey: inspected.publicKey,
|
|
2607
|
+
publicKeyFingerprint: inspected.publicKeyFingerprint,
|
|
2608
|
+
publicKeyPath: inspected.publicKeyPath
|
|
2609
|
+
};
|
|
2610
|
+
}
|
|
2611
|
+
async function inspectHostSealedEnvKey(record, remoteRoot) {
|
|
2612
|
+
const publicKeyFingerprint = record?.sealedServerEnv?.currentKeyFingerprint;
|
|
2613
|
+
if (typeof publicKeyFingerprint !== "string" || publicKeyFingerprint.length === 0) {
|
|
2614
|
+
return null;
|
|
2615
|
+
}
|
|
2616
|
+
const dataDirectory = path4.join(remoteRoot, "hosts", record.domain, "capsules", record.subname, "data");
|
|
2617
|
+
const paths = hostSealedEnvKeyPaths(dataDirectory, publicKeyFingerprint);
|
|
2618
|
+
const [publicKeyReadable, privateKeyReadable] = await Promise.all([
|
|
2619
|
+
pathReadable(paths.publicKey),
|
|
2620
|
+
pathReadable(paths.privateKey)
|
|
2621
|
+
]);
|
|
2622
|
+
const keyStatus = hostSealedEnvKeyStatus(publicKeyReadable, privateKeyReadable);
|
|
2623
|
+
const base = {
|
|
2624
|
+
publicKeyFingerprint,
|
|
2625
|
+
publicKeyPath: paths.publicKey,
|
|
2626
|
+
status: keyStatus,
|
|
2627
|
+
publicKeyAvailable: publicKeyReadable,
|
|
2628
|
+
privateKeyAvailable: privateKeyReadable
|
|
2629
|
+
};
|
|
2630
|
+
if (!publicKeyReadable) {
|
|
2631
|
+
return base;
|
|
2632
|
+
}
|
|
2633
|
+
try {
|
|
2634
|
+
return {
|
|
2635
|
+
...base,
|
|
2636
|
+
publicKey: await readFile2(paths.publicKey, "utf8")
|
|
2637
|
+
};
|
|
2638
|
+
} catch (error) {
|
|
2639
|
+
if (errorDetails(error).code === "ENOENT") {
|
|
2640
|
+
return { ...base, publicKeyAvailable: false, status: hostSealedEnvKeyStatus(false, privateKeyReadable) };
|
|
2641
|
+
}
|
|
2642
|
+
throw error;
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
function hostSealedEnvKeyStatus(publicKeyReadable, privateKeyReadable) {
|
|
2646
|
+
if (publicKeyReadable && privateKeyReadable) {
|
|
2647
|
+
return "available";
|
|
2648
|
+
}
|
|
2649
|
+
if (!publicKeyReadable && !privateKeyReadable) {
|
|
2650
|
+
return "missing-key-material";
|
|
2651
|
+
}
|
|
2652
|
+
if (!publicKeyReadable) {
|
|
2653
|
+
return "missing-public-key";
|
|
2654
|
+
}
|
|
2655
|
+
return "missing-private-key";
|
|
2656
|
+
}
|
|
2657
|
+
function publicRegistrySealedServerEnv(sealedServerEnv) {
|
|
2658
|
+
return {
|
|
2659
|
+
currentKeyFingerprint: sealedServerEnv.currentKeyFingerprint ?? null
|
|
2660
|
+
};
|
|
2661
|
+
}
|
|
2662
|
+
function publicCurrentRelease(record) {
|
|
2663
|
+
if (!record?.currentRelease) {
|
|
2664
|
+
return null;
|
|
2665
|
+
}
|
|
2666
|
+
const currentRelease = { ...record.currentRelease };
|
|
2667
|
+
const fingerprint = currentReleaseSealedServerEnvFingerprint(record);
|
|
2668
|
+
if (fingerprint) {
|
|
2669
|
+
currentRelease.sealedServerEnv = { publicKeyFingerprint: fingerprint };
|
|
2670
|
+
}
|
|
2671
|
+
return currentRelease;
|
|
2672
|
+
}
|
|
2673
|
+
function currentReleaseSealedServerEnvFingerprint(record) {
|
|
2674
|
+
const releaseId = record?.currentRelease?.id ?? null;
|
|
2675
|
+
if (!releaseId) {
|
|
2676
|
+
return null;
|
|
2677
|
+
}
|
|
2678
|
+
const release = normaliseReleaseHistory(record).find((entry) => entry.id === releaseId);
|
|
2679
|
+
const fingerprint = release?.source?.sealedServerEnv?.publicKeyFingerprint;
|
|
2680
|
+
return typeof fingerprint === "string" ? fingerprint : null;
|
|
2681
|
+
}
|
|
2682
|
+
function hostSealedEnvKeyPaths(dataDirectory, fingerprint) {
|
|
2683
|
+
const root = path4.join(dataDirectory, "sealed-server-env");
|
|
2684
|
+
const keys = path4.join(root, "keys");
|
|
2685
|
+
return {
|
|
2686
|
+
root,
|
|
2687
|
+
keys,
|
|
2688
|
+
privateKey: path4.join(keys, `${fingerprint}.private.pem`),
|
|
2689
|
+
publicKey: path4.join(keys, `${fingerprint}.public.pem`)
|
|
2690
|
+
};
|
|
2691
|
+
}
|
|
2692
|
+
function fingerprintPublicKey(publicKey) {
|
|
2693
|
+
return createHash("sha256").update(publicKey).digest("hex").slice(0, 16);
|
|
2694
|
+
}
|
|
2695
|
+
function reactivateRegistrationRecord(record, sealedServerEnv = null) {
|
|
2696
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2697
|
+
const { unregistered, unregisteredAt, deleteAfter, ...activeRecord } = record;
|
|
2698
|
+
return {
|
|
2699
|
+
...activeRecord,
|
|
2700
|
+
status: "registered",
|
|
2701
|
+
updatedAt: now,
|
|
2702
|
+
...sealedServerEnv ? { sealedServerEnv: { currentKeyFingerprint: sealedServerEnv.publicKeyFingerprint } } : {}
|
|
2703
|
+
};
|
|
2704
|
+
}
|
|
2705
|
+
function normaliseHostLogs(request) {
|
|
2706
|
+
const provided = request.logs ?? {};
|
|
2707
|
+
const lines = provided.lines ?? hostHelperConfig.logs.defaultLines;
|
|
2708
|
+
const explicitFile = Boolean(provided.file ?? provided.path ?? provided.accessLog?.file);
|
|
2709
|
+
const source = provided.source === "caddy-combined" ? "http" : provided.source ?? "http";
|
|
2710
|
+
const subname = request.capsule?.subname;
|
|
2711
|
+
const containerName = provided.container?.name ?? (subname ? createHostedContainerName(request.host.domain, subname) : null);
|
|
2712
|
+
const capsuleScoped = source === "http" && typeof subname === "string" && subname.length > 0;
|
|
2713
|
+
return {
|
|
2714
|
+
source,
|
|
2715
|
+
lines,
|
|
2716
|
+
file: provided.file ?? provided.path ?? provided.accessLog?.file ?? (capsuleScoped ? defaultCapsuleHttpLogPath(request.host.remoteRoot, request.host.domain, subname) : defaultCaddyAccessLogPath(request.host.remoteRoot)),
|
|
2717
|
+
explicitFile,
|
|
2718
|
+
capsuleScoped,
|
|
2719
|
+
subname,
|
|
2720
|
+
container: containerName ? { name: containerName } : null
|
|
2721
|
+
};
|
|
2722
|
+
}
|
|
2723
|
+
function normaliseDockerStats(raw) {
|
|
2724
|
+
const [memoryUsageBytes, memoryLimitBytes] = parsePair(raw.MemUsage, parseDockerByteSize);
|
|
2725
|
+
const [networkInputBytes, networkOutputBytes] = parsePair(raw.NetIO, parseDockerByteSize);
|
|
2726
|
+
const [blockInputBytes, blockOutputBytes] = parsePair(raw.BlockIO, parseDockerByteSize);
|
|
2727
|
+
return {
|
|
2728
|
+
cpuPercent: parseDockerPercent(raw.CPUPerc),
|
|
2729
|
+
memoryUsageBytes,
|
|
2730
|
+
memoryLimitBytes,
|
|
2731
|
+
memoryPercent: parseDockerPercent(raw.MemPerc),
|
|
2732
|
+
networkInputBytes,
|
|
2733
|
+
networkOutputBytes,
|
|
2734
|
+
blockInputBytes,
|
|
2735
|
+
blockOutputBytes,
|
|
2736
|
+
pids: parseDockerInteger(raw.PIDs)
|
|
2737
|
+
};
|
|
2738
|
+
}
|
|
2739
|
+
function percentage(numerator, denominator) {
|
|
2740
|
+
if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) {
|
|
2741
|
+
return 0;
|
|
2742
|
+
}
|
|
2743
|
+
return Math.round(numerator / denominator * 1e4) / 100;
|
|
2744
|
+
}
|
|
2745
|
+
function normaliseBootstrap(request) {
|
|
2746
|
+
const provided = request.bootstrap ?? {};
|
|
2747
|
+
const remoteRoot = request.host.remoteRoot;
|
|
2748
|
+
const domain = request.host.domain;
|
|
2749
|
+
const caddyDirectory = path4.join(remoteRoot, "caddy");
|
|
2750
|
+
const domainDirectory = path4.join(remoteRoot, "hosts", domain);
|
|
2751
|
+
const tlsDirectory = path4.join(domainDirectory, "tls");
|
|
2752
|
+
const tlsMode = provided.tls?.mode ?? "automatic";
|
|
2753
|
+
const directories = {
|
|
2754
|
+
remoteRoot,
|
|
2755
|
+
bin: path4.join(remoteRoot, "bin"),
|
|
2756
|
+
incoming: path4.join(remoteRoot, "incoming"),
|
|
2757
|
+
caddy: caddyDirectory,
|
|
2758
|
+
caddyHosts: path4.join(caddyDirectory, "hosts"),
|
|
2759
|
+
hosts: path4.join(remoteRoot, "hosts"),
|
|
2760
|
+
domain: domainDirectory,
|
|
2761
|
+
tls: tlsDirectory,
|
|
2762
|
+
registry: path4.join(domainDirectory, "registry"),
|
|
2763
|
+
capsules: path4.join(domainDirectory, "capsules"),
|
|
2764
|
+
...provided.directories ?? {}
|
|
2765
|
+
};
|
|
2766
|
+
directories.incoming = provided.directories?.incoming ?? path4.join(remoteRoot, "incoming");
|
|
2767
|
+
const tls = {
|
|
2768
|
+
mode: tlsMode,
|
|
2769
|
+
directory: provided.tls?.directory ?? directories.tls,
|
|
2770
|
+
certificate: tlsMode === "cloudflare-origin" ? provided.tls?.certificate ?? path4.join(directories.tls, "origin.crt") : null,
|
|
2771
|
+
key: tlsMode === "cloudflare-origin" ? provided.tls?.key ?? path4.join(directories.tls, "origin.key") : null
|
|
2772
|
+
};
|
|
2773
|
+
return {
|
|
2774
|
+
substrate: {
|
|
2775
|
+
packages: provided.substrate?.packages ?? ["docker", "caddy"],
|
|
2776
|
+
services: provided.substrate?.services ?? ["docker", "caddy"]
|
|
2777
|
+
},
|
|
2778
|
+
directories,
|
|
2779
|
+
domainDirectory: provided.domainDirectory ?? directories.domain,
|
|
2780
|
+
tls,
|
|
2781
|
+
network: provided.network ?? hostHelperConfig.hostedCapsule.dockerNetwork,
|
|
2782
|
+
caddy: {
|
|
2783
|
+
caddyfile: path4.join(directories.caddy, "Caddyfile"),
|
|
2784
|
+
managedInclude: provided.caddy?.managedInclude ?? path4.join(directories.caddy, "sporades-hosted-domains.caddy"),
|
|
2785
|
+
domainInclude: provided.caddy?.domainInclude ?? path4.join(directories.caddyHosts, `${domain}.caddy`),
|
|
2786
|
+
routesDirectory: path4.join(directories.caddyHosts, domain),
|
|
2787
|
+
healthRoute: path4.join(directories.caddyHosts, domain, "host.caddy"),
|
|
2788
|
+
accessLog: provided.caddy?.accessLog ?? defaultCaddyAccessLogPath(remoteRoot)
|
|
2789
|
+
}
|
|
2790
|
+
};
|
|
2791
|
+
}
|
|
2792
|
+
async function ensureBootstrapDirectories(bootstrap) {
|
|
2793
|
+
const directories = [
|
|
2794
|
+
bootstrap.directories.remoteRoot,
|
|
2795
|
+
bootstrap.directories.bin,
|
|
2796
|
+
bootstrap.directories.incoming,
|
|
2797
|
+
bootstrap.directories.caddy,
|
|
2798
|
+
path4.dirname(bootstrap.caddy.accessLog),
|
|
2799
|
+
bootstrap.directories.caddyHosts,
|
|
2800
|
+
bootstrap.directories.hosts,
|
|
2801
|
+
bootstrap.directories.domain,
|
|
2802
|
+
bootstrap.directories.tls,
|
|
2803
|
+
bootstrap.directories.registry,
|
|
2804
|
+
path4.join(bootstrap.directories.registry, "capsules"),
|
|
2805
|
+
bootstrap.directories.capsules,
|
|
2806
|
+
bootstrap.caddy.routesDirectory
|
|
2807
|
+
];
|
|
2808
|
+
for (const directory of directories) {
|
|
2809
|
+
await mkdir(directory, { recursive: true });
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
async function provisionCaddyAccessLog(request, bootstrap) {
|
|
2813
|
+
const logFile = bootstrap.caddy.accessLog;
|
|
2814
|
+
const logDirectory = path4.dirname(logFile);
|
|
2815
|
+
const caddyUser = resolveCaddyServiceUser();
|
|
2816
|
+
if (!caddyUser) {
|
|
2817
|
+
throw helperError(
|
|
2818
|
+
"Caddy service user could not be found.",
|
|
2819
|
+
"Install Caddy with its system service user available, then rerun `sporades host bootstrap`."
|
|
2820
|
+
);
|
|
2821
|
+
}
|
|
2822
|
+
await mkdir(logDirectory, { recursive: true });
|
|
2823
|
+
await writeFile(logFile, "", { flag: "a" });
|
|
2824
|
+
await chmod(logDirectory, 488);
|
|
2825
|
+
await chmod(logFile, 416);
|
|
2826
|
+
const chown2 = spawnSync2("chown", [`${caddyUser.uid}:${caddyUser.gid}`, logDirectory, logFile], { encoding: "utf8" });
|
|
2827
|
+
if (chown2.error || chown2.status !== 0) {
|
|
2828
|
+
throw helperError(
|
|
2829
|
+
"Failed to provision the Caddy access log for the service user.",
|
|
2830
|
+
`Ensure the Host helper runs with permission to chown ${logDirectory} and ${logFile}, then rerun \`sporades host bootstrap --host ${request.host.alias}\`.`
|
|
2831
|
+
);
|
|
2832
|
+
}
|
|
2833
|
+
return {
|
|
2834
|
+
file: logFile,
|
|
2835
|
+
directory: logDirectory,
|
|
2836
|
+
owner: caddyUser.name,
|
|
2837
|
+
writableByService: true
|
|
2838
|
+
};
|
|
2839
|
+
}
|
|
2840
|
+
async function provisionRouteLogFile(route) {
|
|
2841
|
+
const logFile = route.log?.file;
|
|
2842
|
+
if (!logFile) {
|
|
2843
|
+
return;
|
|
2844
|
+
}
|
|
2845
|
+
const logDirectory = path4.dirname(logFile);
|
|
2846
|
+
await mkdir(logDirectory, { recursive: true });
|
|
2847
|
+
await writeFile(logFile, "", { flag: "a" });
|
|
2848
|
+
await chmod(logDirectory, 488);
|
|
2849
|
+
await chmod(logFile, 416);
|
|
2850
|
+
const caddyUser = resolveCaddyServiceUser();
|
|
2851
|
+
if (!caddyUser) {
|
|
2852
|
+
return;
|
|
2853
|
+
}
|
|
2854
|
+
const chown2 = spawnSync2("chown", [`${caddyUser.uid}:${caddyUser.gid}`, logDirectory, logFile], { encoding: "utf8" });
|
|
2855
|
+
if (chown2.error || chown2.status !== 0) {
|
|
2856
|
+
throw helperError(
|
|
2857
|
+
"Failed to provision the Hosted Capsule HTTP log for the Caddy service user.",
|
|
2858
|
+
`Ensure the Host helper runs with permission to chown ${logDirectory} and ${logFile}, then retry the Hosted Capsule command.`
|
|
2859
|
+
);
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
function resolveCaddyServiceUser() {
|
|
2863
|
+
const user = spawnSync2("id", ["-u", "caddy"], { encoding: "utf8" });
|
|
2864
|
+
const group = spawnSync2("id", ["-g", "caddy"], { encoding: "utf8" });
|
|
2865
|
+
if (user.error || user.status !== 0 || group.error || group.status !== 0) {
|
|
2866
|
+
return null;
|
|
2867
|
+
}
|
|
2868
|
+
return { name: "caddy", uid: user.stdout.trim(), gid: group.stdout.trim() };
|
|
2869
|
+
}
|
|
2870
|
+
async function validateBootstrapTls(request, bootstrap) {
|
|
2871
|
+
if (bootstrap.tls.mode === "automatic") {
|
|
2872
|
+
return;
|
|
2873
|
+
}
|
|
2874
|
+
if (bootstrap.tls.mode !== "cloudflare-origin") {
|
|
2875
|
+
throw helperError(
|
|
2876
|
+
"Invalid Host TLS mode.",
|
|
2877
|
+
"Use `--tls automatic` for Caddy-managed certificates or `--tls cloudflare-origin` for preinstalled Cloudflare origin certificates."
|
|
2878
|
+
);
|
|
2879
|
+
}
|
|
2880
|
+
const readable = await Promise.all([
|
|
2881
|
+
pathReadable(bootstrap.tls.certificate),
|
|
2882
|
+
pathReadable(bootstrap.tls.key)
|
|
2883
|
+
]);
|
|
2884
|
+
if (!readable.every(Boolean)) {
|
|
2885
|
+
throw helperError(
|
|
2886
|
+
"Cloudflare origin certificate material is missing or unusable.",
|
|
2887
|
+
`Install readable Cloudflare origin certificate and key files at ${bootstrap.tls.certificate} and ${bootstrap.tls.key}, then rerun \`sporades host bootstrap --host ${request.host.alias}\`.`
|
|
2888
|
+
);
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
function ensureDockerNetwork(networkName) {
|
|
2892
|
+
const inspect = spawnSync2("docker", ["network", "inspect", networkName], { encoding: "utf8" });
|
|
2893
|
+
if (inspect.error) {
|
|
2894
|
+
throw helperError(
|
|
2895
|
+
"Docker is unavailable on the Host server.",
|
|
2896
|
+
"Install Docker, ensure the Docker daemon is running, then rerun `sporades host bootstrap`."
|
|
2897
|
+
);
|
|
2898
|
+
}
|
|
2899
|
+
if (inspect.status === 0) {
|
|
2900
|
+
return { name: networkName, created: false };
|
|
2901
|
+
}
|
|
2902
|
+
const create = spawnSync2("docker", ["network", "create", networkName], { encoding: "utf8" });
|
|
2903
|
+
if (create.error || create.status !== 0) {
|
|
2904
|
+
throw helperError(
|
|
2905
|
+
"Failed to create the Hosted Capsule Docker network.",
|
|
2906
|
+
`Check Docker on the Host server, then rerun \`sporades host bootstrap\`. Docker stderr: ${trimForHint(create.stderr)}`
|
|
2907
|
+
);
|
|
2908
|
+
}
|
|
2909
|
+
return { name: networkName, created: true };
|
|
2910
|
+
}
|
|
2911
|
+
async function installCaddyBootstrapConfig(request, bootstrap) {
|
|
2912
|
+
const caddyfile = bootstrap.caddy.caddyfile;
|
|
2913
|
+
const managedInclude = bootstrap.caddy.managedInclude;
|
|
2914
|
+
const domainInclude = bootstrap.caddy.domainInclude;
|
|
2915
|
+
const placeholderRoute = path4.join(bootstrap.caddy.routesDirectory, ".sporades-placeholder.caddy");
|
|
2916
|
+
await writeFile(placeholderRoute, "# Sporades keeps this placeholder so Caddy route imports are valid before Capsules are registered.\n");
|
|
2917
|
+
await writeFile(bootstrap.caddy.healthRoute, renderHostHealthRoute(request.host.domain, bootstrap.tls));
|
|
2918
|
+
await writeManagedCaddyfile(caddyfile, `import ${managedInclude}`);
|
|
2919
|
+
await writeFile(managedInclude, `# Sporades-managed Hosted domain include list.
|
|
2920
|
+
import ${path4.join(bootstrap.directories.caddyHosts, "*.caddy")}
|
|
2921
|
+
`);
|
|
2922
|
+
await writeFile(domainInclude, `# Sporades-managed routes for ${request.host.domain}.
|
|
2923
|
+
import ${path4.join(bootstrap.caddy.routesDirectory, "*.caddy")}
|
|
2924
|
+
`);
|
|
2925
|
+
validateCaddyBootstrap(caddyfile);
|
|
2926
|
+
reloadCaddyBootstrap(caddyfile);
|
|
2927
|
+
return {
|
|
2928
|
+
caddyfile,
|
|
2929
|
+
managedInclude,
|
|
2930
|
+
domainInclude,
|
|
2931
|
+
routesDirectory: bootstrap.caddy.routesDirectory,
|
|
2932
|
+
health: {
|
|
2933
|
+
hostname: `host.${request.host.domain}`,
|
|
2934
|
+
path: "/__sporades/health",
|
|
2935
|
+
url: `${request.host.scheme ?? "https"}://host.${request.host.domain}/__sporades/health`
|
|
2936
|
+
},
|
|
2937
|
+
globalConfigReplaced: false,
|
|
2938
|
+
reloaded: true
|
|
2939
|
+
};
|
|
2940
|
+
}
|
|
2941
|
+
function renderHostHealthRoute(domain, tls) {
|
|
2942
|
+
const route = {
|
|
2943
|
+
hostname: `host.${domain}`,
|
|
2944
|
+
routeFile: "",
|
|
2945
|
+
tls
|
|
2946
|
+
};
|
|
2947
|
+
return renderRoute(
|
|
2948
|
+
route,
|
|
2949
|
+
[
|
|
2950
|
+
"header /__sporades/health Content-Type application/json",
|
|
2951
|
+
'respond /__sporades/health "{\\"ok\\":true}" 200',
|
|
2952
|
+
"respond 404"
|
|
2953
|
+
].join("\n ")
|
|
2954
|
+
);
|
|
2955
|
+
}
|
|
2956
|
+
async function writeManagedCaddyfile(caddyfile, importLine) {
|
|
2957
|
+
const begin = "# BEGIN Sporades hosted domains";
|
|
2958
|
+
const end = "# END Sporades hosted domains";
|
|
2959
|
+
const block = `${begin}
|
|
2960
|
+
${importLine}
|
|
2961
|
+
${end}
|
|
2962
|
+
`;
|
|
2963
|
+
let existing = "";
|
|
2964
|
+
try {
|
|
2965
|
+
existing = await readFile2(caddyfile, "utf8");
|
|
2966
|
+
} catch (error) {
|
|
2967
|
+
if (errorDetails(error).code !== "ENOENT") {
|
|
2968
|
+
throw error;
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
let next;
|
|
2972
|
+
const markerPattern = new RegExp(`${escapeRegExp(begin)}\\n[\\s\\S]*?${escapeRegExp(end)}\\n?`);
|
|
2973
|
+
if (markerPattern.test(existing)) {
|
|
2974
|
+
next = existing.replace(markerPattern, block);
|
|
2975
|
+
} else {
|
|
2976
|
+
const prefix = existing && !existing.endsWith("\n") ? `${existing}
|
|
2977
|
+
` : existing;
|
|
2978
|
+
next = `${prefix}${existing ? "\n" : ""}${block}`;
|
|
2979
|
+
}
|
|
2980
|
+
await writeFile(caddyfile, next);
|
|
2981
|
+
}
|
|
2982
|
+
function validateCaddyBootstrap(caddyfile) {
|
|
2983
|
+
const result = spawnSync2("caddy", ["validate", "--config", caddyfile, "--adapter", "caddyfile"], { encoding: "utf8" });
|
|
2984
|
+
if (result.error || result.status !== 0) {
|
|
2985
|
+
throw helperError(
|
|
2986
|
+
"Failed to validate the Sporades Caddy bootstrap configuration.",
|
|
2987
|
+
`Check Caddy on the Host server, then rerun \`sporades host bootstrap\`. Caddy stderr: ${trimForHint(result.stderr)}`
|
|
2988
|
+
);
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
function reloadCaddyBootstrap(caddyfile) {
|
|
2992
|
+
const result = spawnSync2("caddy", ["reload", "--config", caddyfile, "--adapter", "caddyfile"], { encoding: "utf8" });
|
|
2993
|
+
if (result.error || result.status !== 0) {
|
|
2994
|
+
throw helperError(
|
|
2995
|
+
"Failed to reload the Sporades Caddy bootstrap configuration.",
|
|
2996
|
+
`Check the Host server Caddy service, then rerun \`sporades host bootstrap\`. Caddy stderr: ${trimForHint(result.stderr)}`
|
|
2997
|
+
);
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
function parsePair(value, parser) {
|
|
3001
|
+
const [left, right] = String(value ?? "").split("/").map((part) => part.trim());
|
|
3002
|
+
return [parser(left), parser(right)];
|
|
3003
|
+
}
|
|
3004
|
+
function parseDockerPercent(value) {
|
|
3005
|
+
const parsed = Number.parseFloat(String(value ?? "").replace("%", "").trim());
|
|
3006
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
3007
|
+
}
|
|
3008
|
+
function parseDockerInteger(value) {
|
|
3009
|
+
const parsed = Number.parseInt(String(value ?? "").trim(), 10);
|
|
3010
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
3011
|
+
}
|
|
3012
|
+
function parseDockerByteSize(value) {
|
|
3013
|
+
const match = String(value ?? "").trim().match(/^([\d.]+)\s*([KMGTPE]?i?B|B)$/i);
|
|
3014
|
+
if (!match) {
|
|
3015
|
+
return null;
|
|
3016
|
+
}
|
|
3017
|
+
const amount = Number.parseFloat(match[1]);
|
|
3018
|
+
if (!Number.isFinite(amount)) {
|
|
3019
|
+
return null;
|
|
3020
|
+
}
|
|
3021
|
+
const unit = match[2].toLowerCase();
|
|
3022
|
+
const multipliers = {
|
|
3023
|
+
b: 1,
|
|
3024
|
+
kb: 1e3,
|
|
3025
|
+
mb: 1e6,
|
|
3026
|
+
gb: 1e9,
|
|
3027
|
+
tb: 1e12,
|
|
3028
|
+
pb: 1e15,
|
|
3029
|
+
eb: 1e18,
|
|
3030
|
+
kib: 1024,
|
|
3031
|
+
mib: 1024 ** 2,
|
|
3032
|
+
gib: 1024 ** 3,
|
|
3033
|
+
tib: 1024 ** 4,
|
|
3034
|
+
pib: 1024 ** 5,
|
|
3035
|
+
eib: 1024 ** 6
|
|
3036
|
+
};
|
|
3037
|
+
if (!multipliers[unit]) {
|
|
3038
|
+
return null;
|
|
3039
|
+
}
|
|
3040
|
+
return Math.round(amount * multipliers[unit]);
|
|
3041
|
+
}
|
|
3042
|
+
async function dockerRunArgs(lifecycle, releaseId) {
|
|
3043
|
+
const args = [
|
|
3044
|
+
"run",
|
|
3045
|
+
"--detach",
|
|
3046
|
+
"--name",
|
|
3047
|
+
lifecycle.container.name,
|
|
3048
|
+
"--network",
|
|
3049
|
+
lifecycle.container.network,
|
|
3050
|
+
"--restart",
|
|
3051
|
+
restartPolicyForMode("hosted").dockerRestart,
|
|
3052
|
+
"--read-only",
|
|
3053
|
+
"--tmpfs",
|
|
3054
|
+
"/tmp:rw,nosuid,nodev,noexec",
|
|
3055
|
+
"--cap-drop",
|
|
3056
|
+
"ALL",
|
|
3057
|
+
"--security-opt",
|
|
3058
|
+
"no-new-privileges",
|
|
3059
|
+
"--user",
|
|
3060
|
+
lifecycle.container.user,
|
|
3061
|
+
"--log-driver",
|
|
3062
|
+
"json-file",
|
|
3063
|
+
"--log-opt",
|
|
3064
|
+
"max-size=10m",
|
|
3065
|
+
"--log-opt",
|
|
3066
|
+
"max-file=5"
|
|
3067
|
+
];
|
|
3068
|
+
const labels = {
|
|
3069
|
+
...lifecycle.container.labels,
|
|
3070
|
+
"com.sporades.release-id": releaseId
|
|
3071
|
+
};
|
|
3072
|
+
for (const [key, value] of Object.entries(labels)) {
|
|
3073
|
+
args.push("--label", `${key}=${value}`);
|
|
3074
|
+
}
|
|
3075
|
+
for (const mount of lifecycle.mounts.files) {
|
|
3076
|
+
if (mount.optional && !await pathExists(mount.host)) {
|
|
3077
|
+
continue;
|
|
3078
|
+
}
|
|
3079
|
+
if (!mount.optional && mount.container === "/app/.sporades/sealed-server-env/server-env.private.pem" && !await pathReadable(mount.host)) {
|
|
3080
|
+
throw missingHostSealedServerEnvPrivateKeyError(lifecycle, mount);
|
|
3081
|
+
}
|
|
3082
|
+
args.push("--volume", formatMount(mount));
|
|
3083
|
+
if (mount.container === "/app/.env.sporades.server") {
|
|
3084
|
+
args.push("--env-file", mount.host);
|
|
3085
|
+
}
|
|
3086
|
+
if (mount.container === "/app/.sporades/sealed-server-env/server-env.sealed.json") {
|
|
3087
|
+
args.push("--env", `SPORADES_SEALED_SERVER_ENV_PATH=${mount.container}`);
|
|
3088
|
+
}
|
|
3089
|
+
if (mount.container === "/app/.sporades/sealed-server-env/server-env.private.pem") {
|
|
3090
|
+
args.push("--env", `SPORADES_SEALED_SERVER_ENV_PRIVATE_KEY_PATH=${mount.container}`);
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
args.push(
|
|
3094
|
+
"--volume",
|
|
3095
|
+
formatMount(lifecycle.mounts.data),
|
|
3096
|
+
"--workdir",
|
|
3097
|
+
"/app",
|
|
3098
|
+
"--env",
|
|
3099
|
+
"PORT=4000",
|
|
3100
|
+
"--env",
|
|
3101
|
+
"SPORADES_LOG_STDOUT=1",
|
|
3102
|
+
"--env",
|
|
3103
|
+
`SPORADES_RELEASE_ID=${releaseId}`
|
|
3104
|
+
);
|
|
3105
|
+
args.push("--publish", `127.0.0.1::${lifecycle.routes.running.port ?? 4e3}`);
|
|
3106
|
+
args.push(lifecycle.container.image, "node", "/app/server.mjs");
|
|
3107
|
+
return args;
|
|
3108
|
+
}
|
|
3109
|
+
function missingHostSealedServerEnvPrivateKeyError(lifecycle, mount) {
|
|
3110
|
+
const fingerprint = mount.fingerprint ?? null;
|
|
3111
|
+
const expected = fingerprint ? ` Expected sealed-env key fingerprint: ${fingerprint}.` : "";
|
|
3112
|
+
return helperError(
|
|
3113
|
+
"Hosted Capsule Sealed Server env private key is missing.",
|
|
3114
|
+
`Host key material for ${lifecycle.remoteCapsuleId} is missing.${expected} Old Host-encrypted envelopes cannot be decrypted without that private key. To recover, re-key the Hosted Capsule, re-seal from source-of-truth Server env values, then push a new release.`,
|
|
3115
|
+
{
|
|
3116
|
+
capsule: {
|
|
3117
|
+
subname: lifecycle.subname,
|
|
3118
|
+
domain: lifecycle.domain,
|
|
3119
|
+
hostedUrl: lifecycle.hostedUrl,
|
|
3120
|
+
remoteCapsuleId: lifecycle.remoteCapsuleId
|
|
3121
|
+
},
|
|
3122
|
+
sealedServerEnv: {
|
|
3123
|
+
expectedPublicKeyFingerprint: fingerprint,
|
|
3124
|
+
privateKeyPath: mount.host,
|
|
3125
|
+
recovery: "re-key-and-re-seal-from-source-of-truth"
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
);
|
|
3129
|
+
}
|
|
3130
|
+
function stopAndRemoveContainer(containerName) {
|
|
3131
|
+
runDocker(["stop", containerName], { ignoreFailure: true });
|
|
3132
|
+
runDocker(["rm", containerName], { ignoreFailure: true });
|
|
3133
|
+
}
|
|
3134
|
+
function ensureHostedBaseImage(lifecycle) {
|
|
3135
|
+
const image = lifecycle.container.image;
|
|
3136
|
+
const inspect = runDocker(["image", "inspect", image], { ignoreFailure: true });
|
|
3137
|
+
if (inspect.ok) {
|
|
3138
|
+
return;
|
|
3139
|
+
}
|
|
3140
|
+
const pull = runDocker(["pull", image], { ignoreFailure: true });
|
|
3141
|
+
if (pull.ok) {
|
|
3142
|
+
return;
|
|
3143
|
+
}
|
|
3144
|
+
const dockerfilePath = path4.join(lifecycle.remoteRoot, "Dockerfile.base");
|
|
3145
|
+
if (!pathExistsSync(dockerfilePath)) {
|
|
3146
|
+
throw helperError(
|
|
3147
|
+
"Unable to prepare the Sporades Base image.",
|
|
3148
|
+
`Docker could not pull ${image}, and ${dockerfilePath} is missing. Reinstall the Sporades Host helper files, then retry \`sporades host start ${lifecycle.subname} --host <alias>\`.`
|
|
3149
|
+
);
|
|
3150
|
+
}
|
|
3151
|
+
const build = runDocker(["build", "-f", dockerfilePath, "-t", image, lifecycle.remoteRoot], { ignoreFailure: true });
|
|
3152
|
+
if (!build.ok) {
|
|
3153
|
+
throw helperError(
|
|
3154
|
+
"Unable to prepare the Sporades Base image.",
|
|
3155
|
+
`Docker could not pull or build ${image}. Check Docker and ${dockerfilePath} on the Host server, then retry \`sporades host start ${lifecycle.subname} --host <alias>\`.`
|
|
3156
|
+
);
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
function runDocker(args, options = {}) {
|
|
3160
|
+
const result = spawnSync2("docker", args, { encoding: "utf8" });
|
|
3161
|
+
if (options.ignoreFailure) {
|
|
3162
|
+
return { ok: result.status === 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
3163
|
+
}
|
|
3164
|
+
return {
|
|
3165
|
+
ok: !result.error && result.status === 0,
|
|
3166
|
+
stdout: result.stdout ?? "",
|
|
3167
|
+
stderr: result.stderr ?? result.error?.message ?? ""
|
|
3168
|
+
};
|
|
3169
|
+
}
|
|
3170
|
+
function checkContainerRunning(containerName) {
|
|
3171
|
+
return inspectContainerRunning(containerName).running;
|
|
3172
|
+
}
|
|
3173
|
+
function inspectContainerRunning(containerName) {
|
|
3174
|
+
const result = runDocker(["inspect", "-f", "{{.State.Running}}", containerName]);
|
|
3175
|
+
if (!result.ok) {
|
|
3176
|
+
return { ok: false, running: false };
|
|
3177
|
+
}
|
|
3178
|
+
const value = result.stdout.trim();
|
|
3179
|
+
return { ok: true, running: value === "true" };
|
|
3180
|
+
}
|
|
3181
|
+
function inspectLoopbackPublishedPort(containerName, containerPort) {
|
|
3182
|
+
const result = runDocker([
|
|
3183
|
+
"inspect",
|
|
3184
|
+
"-f",
|
|
3185
|
+
`{{(index (index .NetworkSettings.Ports "${containerPort}/tcp") 0).HostIp}}:{{(index (index .NetworkSettings.Ports "${containerPort}/tcp") 0).HostPort}}`,
|
|
3186
|
+
containerName
|
|
3187
|
+
]);
|
|
3188
|
+
if (!result.ok) {
|
|
3189
|
+
return null;
|
|
3190
|
+
}
|
|
3191
|
+
const match = result.stdout.trim().match(/^(127\.0\.0\.1):([1-9][0-9]*)$/);
|
|
3192
|
+
if (!match) {
|
|
3193
|
+
return null;
|
|
3194
|
+
}
|
|
3195
|
+
return {
|
|
3196
|
+
containerPort,
|
|
3197
|
+
hostIp: match[1],
|
|
3198
|
+
hostPort: Number(match[2])
|
|
3199
|
+
};
|
|
3200
|
+
}
|
|
3201
|
+
async function currentReleaseId(currentLink, request) {
|
|
3202
|
+
let target;
|
|
3203
|
+
try {
|
|
3204
|
+
target = await readlink(currentLink);
|
|
3205
|
+
} catch (error) {
|
|
3206
|
+
const details = errorDetails(error);
|
|
3207
|
+
if (details.code === "ENOENT" || details.code === "EINVAL") {
|
|
3208
|
+
throw helperError(
|
|
3209
|
+
"No Hosted Capsule release has been pushed.",
|
|
3210
|
+
`Run \`sporades host push --host ${request.host.alias} --subname ${request.capsule.subname}\` before starting the Hosted Capsule.`
|
|
3211
|
+
);
|
|
3212
|
+
}
|
|
3213
|
+
throw error;
|
|
3214
|
+
}
|
|
3215
|
+
return path4.basename(target);
|
|
3216
|
+
}
|
|
3217
|
+
function loopbackRunningRoute(route, publishedPort) {
|
|
3218
|
+
return {
|
|
3219
|
+
...route,
|
|
3220
|
+
target: "loopback",
|
|
3221
|
+
upstream: `${publishedPort.hostIp}:${publishedPort.hostPort}`,
|
|
3222
|
+
publishedPort
|
|
3223
|
+
};
|
|
3224
|
+
}
|
|
3225
|
+
async function writeRunningRoute(lifecycle, route = lifecycle.routes.running) {
|
|
3226
|
+
await provisionRouteLogFile(route);
|
|
3227
|
+
const proxyLine = `reverse_proxy ${route.upstream ?? `${route.containerName}:${route.port ?? 4e3}`}`;
|
|
3228
|
+
await applyManagedRoute(
|
|
3229
|
+
lifecycle,
|
|
3230
|
+
route.routeFile,
|
|
3231
|
+
renderRoute(route, renderRunningRouteHandler(route, proxyLine))
|
|
3232
|
+
);
|
|
3233
|
+
}
|
|
3234
|
+
function renderRunningRouteHandler(route, proxyLine) {
|
|
3235
|
+
const probe = route.runtimeProbe;
|
|
3236
|
+
if (!probe?.token || probe.header !== RUNTIME_PROBE_HEADER) {
|
|
3237
|
+
return proxyLine;
|
|
3238
|
+
}
|
|
3239
|
+
return [
|
|
3240
|
+
`@sporadesRuntimeHealth path ${CAPSULE_RUNTIME_HEALTH_PATH}`,
|
|
3241
|
+
"@sporadesRuntimeProbe {",
|
|
3242
|
+
` path ${CAPSULE_RUNTIME_HEALTH_PATH}`,
|
|
3243
|
+
` header ${RUNTIME_PROBE_HEADER} ${probe.token}`,
|
|
3244
|
+
"}",
|
|
3245
|
+
"handle @sporadesRuntimeProbe {",
|
|
3246
|
+
` ${proxyLine}`,
|
|
3247
|
+
"}",
|
|
3248
|
+
"respond @sporadesRuntimeHealth 404",
|
|
3249
|
+
proxyLine
|
|
3250
|
+
].join("\n ");
|
|
3251
|
+
}
|
|
3252
|
+
async function writeUnavailableRoute(lifecycle) {
|
|
3253
|
+
const route = lifecycle.routes.unavailable;
|
|
3254
|
+
await provisionRouteLogFile(route);
|
|
3255
|
+
await applyManagedRoute(
|
|
3256
|
+
lifecycle,
|
|
3257
|
+
route.routeFile,
|
|
3258
|
+
renderRoute(route, renderUnavailableRouteHandler(route))
|
|
3259
|
+
);
|
|
3260
|
+
}
|
|
3261
|
+
function renderUnavailableRouteHandler(route) {
|
|
3262
|
+
return [
|
|
3263
|
+
`@sporadesRuntimeHealth path ${CAPSULE_RUNTIME_HEALTH_PATH}`,
|
|
3264
|
+
"respond @sporadesRuntimeHealth 404",
|
|
3265
|
+
`respond "Hosted Capsule unavailable" ${route.statusCode ?? 503}`
|
|
3266
|
+
].join("\n ");
|
|
3267
|
+
}
|
|
3268
|
+
function renderRoute(route, handlerLine) {
|
|
3269
|
+
const tlsLine = renderRouteTlsLine(route.tls);
|
|
3270
|
+
const logBlock = renderRouteLogBlock(route.log);
|
|
3271
|
+
return `${route.hostname} {
|
|
3272
|
+
${tlsLine}${logBlock} ${handlerLine}
|
|
3273
|
+
}
|
|
3274
|
+
`;
|
|
3275
|
+
}
|
|
3276
|
+
function renderRouteTlsLine(tls) {
|
|
3277
|
+
if (tls?.mode !== "cloudflare-origin") {
|
|
3278
|
+
return "";
|
|
3279
|
+
}
|
|
3280
|
+
return ` tls ${tls.certificate} ${tls.key}
|
|
3281
|
+
`;
|
|
3282
|
+
}
|
|
3283
|
+
function renderRouteLogBlock(log) {
|
|
3284
|
+
if (!log?.file) {
|
|
3285
|
+
return "";
|
|
3286
|
+
}
|
|
3287
|
+
return ` log {
|
|
3288
|
+
output file ${log.file} {
|
|
3289
|
+
roll_size 10MiB
|
|
3290
|
+
roll_keep 5
|
|
3291
|
+
roll_keep_for 720h
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
`;
|
|
3295
|
+
}
|
|
3296
|
+
async function applyManagedRoute(lifecycle, routeFile, contents) {
|
|
3297
|
+
await mkdir(path4.dirname(routeFile), { recursive: true });
|
|
3298
|
+
const tempRouteFile = `${routeFile}.tmp`;
|
|
3299
|
+
const previousRouteFile = `${routeFile}.previous-${process.pid}`;
|
|
3300
|
+
await rm2(tempRouteFile, { force: true });
|
|
3301
|
+
await rm2(previousRouteFile, { force: true });
|
|
3302
|
+
await writeFile(tempRouteFile, contents);
|
|
3303
|
+
try {
|
|
3304
|
+
validateCaddyRoute(tempRouteFile);
|
|
3305
|
+
} catch (error) {
|
|
3306
|
+
await rm2(tempRouteFile, { force: true });
|
|
3307
|
+
throw error;
|
|
3308
|
+
}
|
|
3309
|
+
const hadPreviousRoute = await pathExists(routeFile);
|
|
3310
|
+
if (hadPreviousRoute) {
|
|
3311
|
+
await rename(routeFile, previousRouteFile);
|
|
3312
|
+
}
|
|
3313
|
+
try {
|
|
3314
|
+
await rename(tempRouteFile, routeFile);
|
|
3315
|
+
reloadCaddy(lifecycle);
|
|
3316
|
+
} catch (error) {
|
|
3317
|
+
await rm2(routeFile, { force: true });
|
|
3318
|
+
if (hadPreviousRoute) {
|
|
3319
|
+
await rename(previousRouteFile, routeFile);
|
|
3320
|
+
}
|
|
3321
|
+
try {
|
|
3322
|
+
reloadCaddy(lifecycle);
|
|
3323
|
+
} catch (rollbackError) {
|
|
3324
|
+
throw helperError(
|
|
3325
|
+
"Failed to apply Hosted Capsule route and failed to reload the restored Caddy config.",
|
|
3326
|
+
"The previous route file was restored, but Caddy could not reload it. Check the Host server Caddy service and configuration, then retry the lifecycle command."
|
|
3327
|
+
);
|
|
3328
|
+
}
|
|
3329
|
+
throw error;
|
|
3330
|
+
}
|
|
3331
|
+
await rm2(previousRouteFile, { force: true });
|
|
3332
|
+
}
|
|
3333
|
+
async function removeManagedRoute(lifecycle, routeFile) {
|
|
3334
|
+
const previousRouteFile = `${routeFile}.previous-${process.pid}`;
|
|
3335
|
+
await rm2(previousRouteFile, { force: true });
|
|
3336
|
+
const hadRoute = await pathExists(routeFile);
|
|
3337
|
+
if (!hadRoute) {
|
|
3338
|
+
return { routeFile, removed: false };
|
|
3339
|
+
}
|
|
3340
|
+
await rename(routeFile, previousRouteFile);
|
|
3341
|
+
try {
|
|
3342
|
+
reloadCaddy(lifecycle);
|
|
3343
|
+
} catch (error) {
|
|
3344
|
+
await rename(previousRouteFile, routeFile);
|
|
3345
|
+
try {
|
|
3346
|
+
reloadCaddy(lifecycle);
|
|
3347
|
+
} catch {
|
|
3348
|
+
throw helperError(
|
|
3349
|
+
"Failed to remove Hosted Capsule route and failed to reload the restored Caddy config.",
|
|
3350
|
+
"The previous route file was restored, but Caddy could not reload it. Check the Host server Caddy service and configuration, then retry unregister."
|
|
3351
|
+
);
|
|
3352
|
+
}
|
|
3353
|
+
throw helperError(
|
|
3354
|
+
"Failed to remove Hosted Capsule route.",
|
|
3355
|
+
"The previous route file was restored. Check the Host server Caddy service and configuration, then retry unregister."
|
|
3356
|
+
);
|
|
3357
|
+
}
|
|
3358
|
+
return { routeFile, removed: true, previousRouteFile };
|
|
3359
|
+
}
|
|
3360
|
+
async function finalizeRemovedRoute(route) {
|
|
3361
|
+
if (route?.previousRouteFile) {
|
|
3362
|
+
await rm2(route.previousRouteFile, { force: true });
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
async function restoreRemovedRoute(lifecycle, route) {
|
|
3366
|
+
if (!route?.previousRouteFile) {
|
|
3367
|
+
return;
|
|
3368
|
+
}
|
|
3369
|
+
await rm2(route.routeFile, { force: true });
|
|
3370
|
+
await rename(route.previousRouteFile, route.routeFile);
|
|
3371
|
+
reloadCaddy(lifecycle);
|
|
3372
|
+
}
|
|
3373
|
+
async function removePathIfPresent(targetPath, options = {}) {
|
|
3374
|
+
const existed = await pathExists(targetPath);
|
|
3375
|
+
if (!existed) {
|
|
3376
|
+
return { path: targetPath, removed: false };
|
|
3377
|
+
}
|
|
3378
|
+
await rm2(targetPath, { recursive: Boolean(options.recursive), force: true });
|
|
3379
|
+
return { path: targetPath, removed: true };
|
|
3380
|
+
}
|
|
3381
|
+
async function prepareWritableDataPath(targetPath) {
|
|
3382
|
+
let stats;
|
|
3383
|
+
try {
|
|
3384
|
+
stats = await lstat(targetPath);
|
|
3385
|
+
} catch (error) {
|
|
3386
|
+
if (errorDetails(error).code === "ENOENT") {
|
|
3387
|
+
return;
|
|
3388
|
+
}
|
|
3389
|
+
throw error;
|
|
3390
|
+
}
|
|
3391
|
+
if (stats.isSymbolicLink()) {
|
|
3392
|
+
throw helperError(
|
|
3393
|
+
"Hosted Capsule data path contains a symbolic link.",
|
|
3394
|
+
`Remove the symbolic link at ${targetPath}, then retry the Host lifecycle command.`
|
|
3395
|
+
);
|
|
3396
|
+
}
|
|
3397
|
+
await prepareRuntimeDataOwnership(targetPath, stats);
|
|
3398
|
+
if (stats.isDirectory()) {
|
|
3399
|
+
await chmod(targetPath, 448);
|
|
3400
|
+
const entries = await readdir2(targetPath, { withFileTypes: true });
|
|
3401
|
+
for (const entry of entries) {
|
|
3402
|
+
await prepareWritableDataPath(path4.join(targetPath, entry.name));
|
|
3403
|
+
}
|
|
3404
|
+
return;
|
|
3405
|
+
}
|
|
3406
|
+
if (stats.isFile()) {
|
|
3407
|
+
await chmod(targetPath, 384);
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
async function prepareRuntimeDataOwnership(targetPath, stats) {
|
|
3411
|
+
const uid = SPORADES_BASE_IMAGE.runtimeUid;
|
|
3412
|
+
const gid = SPORADES_BASE_IMAGE.runtimeGid;
|
|
3413
|
+
if (process.env.SPORADES_TEST_FORCE_RUNTIME_DATA_CHOWN_FAILURE === "1") {
|
|
3414
|
+
throw runtimeDataOwnershipError(targetPath, uid, gid);
|
|
3415
|
+
}
|
|
3416
|
+
if (stats.uid === uid && stats.gid === gid) {
|
|
3417
|
+
return;
|
|
3418
|
+
}
|
|
3419
|
+
try {
|
|
3420
|
+
await chown(targetPath, uid, gid);
|
|
3421
|
+
} catch (error) {
|
|
3422
|
+
if (process.env.SPORADES_TEST_ALLOW_RUNTIME_DATA_OWNER_FALLBACK === "1" && ["EPERM", "EINVAL"].includes(String(errorDetails(error).code ?? ""))) {
|
|
3423
|
+
return;
|
|
3424
|
+
}
|
|
3425
|
+
throw runtimeDataOwnershipError(targetPath, uid, gid);
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
function runtimeDataOwnershipError(targetPath, uid, gid) {
|
|
3429
|
+
return helperError(
|
|
3430
|
+
"Unable to prepare Hosted Capsule data ownership for the non-root runtime user.",
|
|
3431
|
+
`Run the Host helper as a user that can chown Capsule data to ${uid}:${gid}, or repair ownership with \`sudo chown -R ${uid}:${gid} ${targetPath}\` and retry.`
|
|
3432
|
+
);
|
|
3433
|
+
}
|
|
3434
|
+
function validateCaddyRoute(routeFile) {
|
|
3435
|
+
const result = spawnSync2("caddy", ["validate", "--config", routeFile, "--adapter", "caddyfile"], { encoding: "utf8" });
|
|
3436
|
+
if (result.error || result.status !== 0) {
|
|
3437
|
+
throw helperError(
|
|
3438
|
+
"Failed to validate Hosted Capsule route.",
|
|
3439
|
+
"Check the generated Caddy route for this Hosted Capsule, then retry the lifecycle command."
|
|
3440
|
+
);
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
function reloadCaddy(lifecycle) {
|
|
3444
|
+
const configPath = path4.join(lifecycle.remoteRoot, "caddy", "Caddyfile");
|
|
3445
|
+
const result = spawnSync2("caddy", ["reload", "--config", configPath, "--adapter", "caddyfile"], { encoding: "utf8" });
|
|
3446
|
+
if (result.error || result.status !== 0) {
|
|
3447
|
+
throw helperError(
|
|
3448
|
+
"Failed to apply Hosted Capsule route.",
|
|
3449
|
+
"Check the Host server Caddy configuration, then retry the lifecycle command."
|
|
3450
|
+
);
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
async function updateRegistryStatus(request, status) {
|
|
3454
|
+
await mutateRegistryRecord(request, (record) => {
|
|
3455
|
+
record.status = status;
|
|
3456
|
+
record.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3457
|
+
return record;
|
|
3458
|
+
});
|
|
3459
|
+
}
|
|
3460
|
+
async function recordFailedStartAndUnavailableRoute(request, lifecycle, releaseId, failureMessage) {
|
|
3461
|
+
try {
|
|
3462
|
+
await writeUnavailableRoute(lifecycle);
|
|
3463
|
+
} catch (error) {
|
|
3464
|
+
await recordReleaseFailure(request, releaseId, String(errorDetails(error).message ?? "Failed to apply Hosted Capsule route."));
|
|
3465
|
+
throw error;
|
|
3466
|
+
}
|
|
3467
|
+
await recordReleaseFailure(request, releaseId, failureMessage);
|
|
3468
|
+
}
|
|
3469
|
+
async function recordReleaseUploaded(request, release) {
|
|
3470
|
+
await mutateRegistryRecord(request, (record) => {
|
|
3471
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3472
|
+
record.currentRelease = { ...record.currentRelease ?? {}, id: release.id };
|
|
3473
|
+
record.baseImage = normaliseProvidedBaseImage(release.baseImage ?? record.baseImage);
|
|
3474
|
+
record.status = "released";
|
|
3475
|
+
record.updatedAt = now;
|
|
3476
|
+
record.releases = upsertReleaseEntry(record, release.id, (entry) => ({
|
|
3477
|
+
...entry,
|
|
3478
|
+
id: release.id,
|
|
3479
|
+
createdAt: entry.createdAt ?? now,
|
|
3480
|
+
uploadedAt: entry.uploadedAt ?? now,
|
|
3481
|
+
state: "uploaded",
|
|
3482
|
+
current: true,
|
|
3483
|
+
source: {
|
|
3484
|
+
...entry.source ?? {},
|
|
3485
|
+
hostedUrl: release.hostedUrl ?? entry.source?.hostedUrl ?? null,
|
|
3486
|
+
remoteCapsuleId: release.remoteCapsuleId ?? entry.source?.remoteCapsuleId ?? null,
|
|
3487
|
+
files: Array.isArray(release.files) ? [...release.files] : [],
|
|
3488
|
+
serverEnvIncluded: Boolean(release.serverEnvIncluded),
|
|
3489
|
+
sealedServerEnvIncluded: Boolean(release.sealedServerEnvIncluded),
|
|
3490
|
+
sealedServerEnv: release.sealedServerEnv?.publicKeyFingerprint ? { publicKeyFingerprint: release.sealedServerEnv.publicKeyFingerprint } : void 0
|
|
3491
|
+
}
|
|
3492
|
+
}));
|
|
3493
|
+
return record;
|
|
3494
|
+
});
|
|
3495
|
+
}
|
|
3496
|
+
async function recordReleaseStartAttempt(request, releaseId) {
|
|
3497
|
+
await mutateRegistryRecord(request, (record) => {
|
|
3498
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3499
|
+
record.updatedAt = now;
|
|
3500
|
+
record.releases = upsertReleaseEntry(record, releaseId, (entry) => ({
|
|
3501
|
+
...entry,
|
|
3502
|
+
id: releaseId,
|
|
3503
|
+
createdAt: entry.createdAt ?? record.currentRelease?.createdAt ?? now,
|
|
3504
|
+
uploadedAt: entry.uploadedAt ?? null,
|
|
3505
|
+
current: true,
|
|
3506
|
+
startAttempts: [...normaliseReleaseEventList(entry.startAttempts), { startedAt: now }]
|
|
3507
|
+
}));
|
|
3508
|
+
return record;
|
|
3509
|
+
});
|
|
3510
|
+
}
|
|
3511
|
+
async function recordReleaseStarted(request, releaseId) {
|
|
3512
|
+
await mutateRegistryRecord(request, (record) => {
|
|
3513
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3514
|
+
record.currentRelease = { ...record.currentRelease ?? {}, id: releaseId };
|
|
3515
|
+
record.status = "running";
|
|
3516
|
+
record.updatedAt = now;
|
|
3517
|
+
record.releases = upsertReleaseEntry(record, releaseId, (entry) => ({
|
|
3518
|
+
...entry,
|
|
3519
|
+
id: releaseId,
|
|
3520
|
+
createdAt: entry.createdAt ?? now,
|
|
3521
|
+
uploadedAt: entry.uploadedAt ?? null,
|
|
3522
|
+
state: "started",
|
|
3523
|
+
current: true,
|
|
3524
|
+
failure: null
|
|
3525
|
+
}));
|
|
3526
|
+
return record;
|
|
3527
|
+
});
|
|
3528
|
+
}
|
|
3529
|
+
async function recordReleaseVerified(request, releaseId) {
|
|
3530
|
+
await mutateRegistryRecord(request, (record) => {
|
|
3531
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3532
|
+
record.currentRelease = { ...record.currentRelease ?? {}, id: releaseId };
|
|
3533
|
+
record.status = "running";
|
|
3534
|
+
record.updatedAt = now;
|
|
3535
|
+
record.releases = upsertReleaseEntry(record, releaseId, (entry) => ({
|
|
3536
|
+
...entry,
|
|
3537
|
+
id: releaseId,
|
|
3538
|
+
createdAt: entry.createdAt ?? now,
|
|
3539
|
+
uploadedAt: entry.uploadedAt ?? null,
|
|
3540
|
+
state: "verified",
|
|
3541
|
+
current: true,
|
|
3542
|
+
verificationAttempts: [...normaliseReleaseEventList(entry.verificationAttempts), { verifiedAt: now }],
|
|
3543
|
+
failure: null
|
|
3544
|
+
}));
|
|
3545
|
+
return record;
|
|
3546
|
+
});
|
|
3547
|
+
}
|
|
3548
|
+
async function recordReleaseVerificationFailed(request, releaseId, message) {
|
|
3549
|
+
await mutateRegistryRecord(request, (record) => {
|
|
3550
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3551
|
+
record.status = "failed";
|
|
3552
|
+
record.updatedAt = now;
|
|
3553
|
+
record.releases = upsertReleaseEntry(record, releaseId, (entry) => ({
|
|
3554
|
+
...entry,
|
|
3555
|
+
id: releaseId,
|
|
3556
|
+
createdAt: entry.createdAt ?? now,
|
|
3557
|
+
uploadedAt: entry.uploadedAt ?? null,
|
|
3558
|
+
state: "failed",
|
|
3559
|
+
current: (record.currentRelease?.id ?? null) === releaseId,
|
|
3560
|
+
verificationAttempts: [
|
|
3561
|
+
...normaliseReleaseEventList(entry.verificationAttempts),
|
|
3562
|
+
{ failedAt: now, failure: { message } }
|
|
3563
|
+
],
|
|
3564
|
+
failure: {
|
|
3565
|
+
failedAt: now,
|
|
3566
|
+
message
|
|
3567
|
+
}
|
|
3568
|
+
}));
|
|
3569
|
+
return record;
|
|
3570
|
+
});
|
|
3571
|
+
}
|
|
3572
|
+
async function recordReleaseVerificationFallback(request, failedReleaseId, fallbackReleaseId, message) {
|
|
3573
|
+
await mutateRegistryRecord(request, (record) => {
|
|
3574
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3575
|
+
record.currentRelease = { ...record.currentRelease ?? {}, id: fallbackReleaseId };
|
|
3576
|
+
record.status = "running";
|
|
3577
|
+
record.updatedAt = now;
|
|
3578
|
+
record.releases = upsertReleaseEntry(record, failedReleaseId, (entry) => ({
|
|
3579
|
+
...entry,
|
|
3580
|
+
id: failedReleaseId,
|
|
3581
|
+
state: "failed",
|
|
3582
|
+
current: false,
|
|
3583
|
+
fallbackAttempts: [
|
|
3584
|
+
...normaliseReleaseEventList(entry.fallbackAttempts),
|
|
3585
|
+
{ fallbackAt: now, releaseId: fallbackReleaseId, reason: message }
|
|
3586
|
+
],
|
|
3587
|
+
failure: {
|
|
3588
|
+
failedAt: entry.failure?.failedAt ?? now,
|
|
3589
|
+
message
|
|
3590
|
+
}
|
|
3591
|
+
}));
|
|
3592
|
+
record.releases = upsertReleaseEntry(record, fallbackReleaseId, (entry) => ({
|
|
3593
|
+
...entry,
|
|
3594
|
+
id: fallbackReleaseId,
|
|
3595
|
+
current: true,
|
|
3596
|
+
fallbackSelectedAt: now,
|
|
3597
|
+
failure: null
|
|
3598
|
+
}));
|
|
3599
|
+
return record;
|
|
3600
|
+
});
|
|
3601
|
+
}
|
|
3602
|
+
async function recordReleaseVerificationFallbackFailed(request, failedReleaseId, fallbackReleaseId, fallbackMessage, verificationMessage) {
|
|
3603
|
+
await mutateRegistryRecord(request, (record) => {
|
|
3604
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3605
|
+
record.currentRelease = { ...record.currentRelease ?? {}, id: failedReleaseId };
|
|
3606
|
+
record.status = "failed";
|
|
3607
|
+
record.updatedAt = now;
|
|
3608
|
+
record.releases = upsertReleaseEntry(record, failedReleaseId, (entry) => ({
|
|
3609
|
+
...entry,
|
|
3610
|
+
id: failedReleaseId,
|
|
3611
|
+
state: "failed",
|
|
3612
|
+
current: true,
|
|
3613
|
+
fallbackAttempts: [
|
|
3614
|
+
...normaliseReleaseEventList(entry.fallbackAttempts),
|
|
3615
|
+
{
|
|
3616
|
+
failedAt: now,
|
|
3617
|
+
releaseId: fallbackReleaseId,
|
|
3618
|
+
reason: verificationMessage,
|
|
3619
|
+
failure: { message: fallbackMessage }
|
|
3620
|
+
}
|
|
3621
|
+
],
|
|
3622
|
+
failure: {
|
|
3623
|
+
failedAt: entry.failure?.failedAt ?? now,
|
|
3624
|
+
message: verificationMessage
|
|
3625
|
+
}
|
|
3626
|
+
}));
|
|
3627
|
+
record.releases = normaliseReleaseHistory(record).map(
|
|
3628
|
+
(release) => release.id === fallbackReleaseId ? { ...release, current: false } : release
|
|
3629
|
+
);
|
|
3630
|
+
return record;
|
|
3631
|
+
});
|
|
3632
|
+
}
|
|
3633
|
+
async function recordReleaseFailure(request, releaseId, message) {
|
|
3634
|
+
await mutateRegistryRecord(request, (record) => {
|
|
3635
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3636
|
+
record.status = "failed";
|
|
3637
|
+
record.updatedAt = now;
|
|
3638
|
+
record.releases = upsertReleaseEntry(record, releaseId, (entry) => ({
|
|
3639
|
+
...entry,
|
|
3640
|
+
id: releaseId,
|
|
3641
|
+
createdAt: entry.createdAt ?? now,
|
|
3642
|
+
uploadedAt: entry.uploadedAt ?? null,
|
|
3643
|
+
state: "failed",
|
|
3644
|
+
current: (record.currentRelease?.id ?? null) === releaseId,
|
|
3645
|
+
failure: {
|
|
3646
|
+
failedAt: now,
|
|
3647
|
+
message
|
|
3648
|
+
}
|
|
3649
|
+
}));
|
|
3650
|
+
return record;
|
|
3651
|
+
});
|
|
3652
|
+
}
|
|
3653
|
+
async function recordReleaseRollbackSelected(request, releaseId) {
|
|
3654
|
+
await mutateRegistryRecord(request, (record) => {
|
|
3655
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3656
|
+
record.currentRelease = { ...record.currentRelease ?? {}, id: releaseId };
|
|
3657
|
+
record.status = "released";
|
|
3658
|
+
record.updatedAt = now;
|
|
3659
|
+
record.releases = upsertReleaseEntry(record, releaseId, (entry) => ({
|
|
3660
|
+
...entry,
|
|
3661
|
+
id: releaseId,
|
|
3662
|
+
createdAt: entry.createdAt ?? now,
|
|
3663
|
+
uploadedAt: entry.uploadedAt ?? null,
|
|
3664
|
+
current: true,
|
|
3665
|
+
failure: null
|
|
3666
|
+
}));
|
|
3667
|
+
return record;
|
|
3668
|
+
});
|
|
3669
|
+
}
|
|
3670
|
+
function upsertReleaseEntry(record, releaseId, mutateEntry) {
|
|
3671
|
+
const releases = normaliseReleaseHistory(record);
|
|
3672
|
+
const existing = releases.find((release) => release.id === releaseId) ?? createLegacyReleaseEntry(releaseId, record);
|
|
3673
|
+
const next = mutateEntry(existing);
|
|
3674
|
+
const withoutRelease = releases.filter((release) => release.id !== releaseId);
|
|
3675
|
+
return [...withoutRelease, next].map((release) => markCurrentReleaseEntry(release, releaseId));
|
|
3676
|
+
}
|
|
3677
|
+
function normaliseReleaseHistory(record) {
|
|
3678
|
+
const currentReleaseId2 = record?.currentRelease?.id ?? null;
|
|
3679
|
+
const releases = Array.isArray(record?.releases) ? record.releases.filter((release) => release && typeof release === "object" && typeof release.id === "string" && release.id.length > 0).map((release) => normaliseReleaseEntry(release, currentReleaseId2)) : [];
|
|
3680
|
+
if (releases.length === 0 && currentReleaseId2) {
|
|
3681
|
+
releases.push(createLegacyReleaseEntry(currentReleaseId2, record));
|
|
3682
|
+
}
|
|
3683
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3684
|
+
return releases.filter((release) => {
|
|
3685
|
+
if (seen.has(release.id)) {
|
|
3686
|
+
return false;
|
|
3687
|
+
}
|
|
3688
|
+
seen.add(release.id);
|
|
3689
|
+
return true;
|
|
3690
|
+
});
|
|
3691
|
+
}
|
|
3692
|
+
function normaliseReleaseEntry(release, currentReleaseId2) {
|
|
3693
|
+
const releaseState = typeof release.state === "string" ? release.state : "uploaded";
|
|
3694
|
+
const state = ["uploaded", "started", "verified", "failed"].includes(releaseState) ? releaseState : "uploaded";
|
|
3695
|
+
return {
|
|
3696
|
+
id: release.id,
|
|
3697
|
+
createdAt: typeof release.createdAt === "string" ? release.createdAt : null,
|
|
3698
|
+
uploadedAt: typeof release.uploadedAt === "string" ? release.uploadedAt : null,
|
|
3699
|
+
state,
|
|
3700
|
+
current: release.id === currentReleaseId2,
|
|
3701
|
+
source: normaliseReleaseSource(release.source),
|
|
3702
|
+
startAttempts: normaliseReleaseEventList(release.startAttempts),
|
|
3703
|
+
verificationAttempts: normaliseReleaseEventList(release.verificationAttempts),
|
|
3704
|
+
fallbackAttempts: normaliseReleaseEventList(release.fallbackAttempts),
|
|
3705
|
+
fallbackSelectedAt: typeof release.fallbackSelectedAt === "string" ? release.fallbackSelectedAt : null,
|
|
3706
|
+
failure: normaliseReleaseFailure(release.failure)
|
|
3707
|
+
};
|
|
3708
|
+
}
|
|
3709
|
+
function createLegacyReleaseEntry(releaseId, record) {
|
|
3710
|
+
return {
|
|
3711
|
+
id: releaseId,
|
|
3712
|
+
createdAt: typeof record?.currentRelease?.createdAt === "string" ? record.currentRelease.createdAt : null,
|
|
3713
|
+
uploadedAt: null,
|
|
3714
|
+
state: "uploaded",
|
|
3715
|
+
current: true,
|
|
3716
|
+
source: normaliseReleaseSource(record?.currentRelease?.source),
|
|
3717
|
+
startAttempts: [],
|
|
3718
|
+
verificationAttempts: [],
|
|
3719
|
+
failure: null,
|
|
3720
|
+
legacy: true
|
|
3721
|
+
};
|
|
3722
|
+
}
|
|
3723
|
+
function markCurrentReleaseEntry(release, currentReleaseId2) {
|
|
3724
|
+
return {
|
|
3725
|
+
...release,
|
|
3726
|
+
current: release.id === currentReleaseId2
|
|
3727
|
+
};
|
|
3728
|
+
}
|
|
3729
|
+
function normaliseReleaseSource(source) {
|
|
3730
|
+
if (!source || typeof source !== "object" || Array.isArray(source)) {
|
|
3731
|
+
return {};
|
|
3732
|
+
}
|
|
3733
|
+
return Object.fromEntries(
|
|
3734
|
+
Object.entries(source).filter(([, value]) => value !== void 0)
|
|
3735
|
+
);
|
|
3736
|
+
}
|
|
3737
|
+
function releaseSealedServerEnvPrivateKeyMount(registryRecord, paths) {
|
|
3738
|
+
const releaseId = registryRecord?.currentRelease?.id ?? null;
|
|
3739
|
+
const release = normaliseReleaseHistory(registryRecord).find((entry) => entry.id === releaseId);
|
|
3740
|
+
const fingerprint = release?.source?.sealedServerEnv?.publicKeyFingerprint;
|
|
3741
|
+
if (typeof fingerprint === "string" && /^[a-f0-9]{16}$/.test(fingerprint)) {
|
|
3742
|
+
return {
|
|
3743
|
+
host: path4.join(paths.data, "sealed-server-env", "keys", `${fingerprint}.private.pem`),
|
|
3744
|
+
fingerprint
|
|
3745
|
+
};
|
|
3746
|
+
}
|
|
3747
|
+
return {
|
|
3748
|
+
host: path4.join(paths.data, "sealed-server-env", "server-env.private.pem"),
|
|
3749
|
+
fingerprint: null
|
|
3750
|
+
};
|
|
3751
|
+
}
|
|
3752
|
+
function normaliseReleaseEventList(value) {
|
|
3753
|
+
if (!Array.isArray(value)) {
|
|
3754
|
+
return [];
|
|
3755
|
+
}
|
|
3756
|
+
return value.filter((event) => event && typeof event === "object" && !Array.isArray(event));
|
|
3757
|
+
}
|
|
3758
|
+
function normaliseReleaseFailure(value) {
|
|
3759
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
3760
|
+
return null;
|
|
3761
|
+
}
|
|
3762
|
+
const failure = value;
|
|
3763
|
+
return {
|
|
3764
|
+
failedAt: typeof failure.failedAt === "string" ? failure.failedAt : null,
|
|
3765
|
+
message: typeof failure.message === "string" ? failure.message : "Hosted Capsule release failed."
|
|
3766
|
+
};
|
|
3767
|
+
}
|
|
3768
|
+
function compareReleasesNewestFirst(left, right) {
|
|
3769
|
+
return String(right.createdAt ?? right.id).localeCompare(String(left.createdAt ?? left.id)) || right.id.localeCompare(left.id);
|
|
3770
|
+
}
|
|
3771
|
+
async function readRegistryRecordForCapsule(request, purpose) {
|
|
3772
|
+
const registryRecordPath = registryPath(request);
|
|
3773
|
+
try {
|
|
3774
|
+
return JSON.parse(await readFile2(registryRecordPath, "utf8"));
|
|
3775
|
+
} catch (error) {
|
|
3776
|
+
if (errorDetails(error).code === "ENOENT") {
|
|
3777
|
+
throw helperError(
|
|
3778
|
+
"Hosted Capsule is not registered.",
|
|
3779
|
+
missingCapsuleHint(request, purpose)
|
|
3780
|
+
);
|
|
3781
|
+
}
|
|
3782
|
+
if (error instanceof SyntaxError) {
|
|
3783
|
+
throw helperError(
|
|
3784
|
+
"Hosted Capsule registry record is invalid.",
|
|
3785
|
+
"Repair the Host server registry record before retrying the command."
|
|
3786
|
+
);
|
|
3787
|
+
}
|
|
3788
|
+
throw error;
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3791
|
+
async function readOptionalRegistryRecordForCapsule(request) {
|
|
3792
|
+
try {
|
|
3793
|
+
return await readRegistryRecordForCapsule(request, "delete");
|
|
3794
|
+
} catch (error) {
|
|
3795
|
+
if (errorDetails(error).message === "Hosted Capsule is not registered.") {
|
|
3796
|
+
return null;
|
|
3797
|
+
}
|
|
3798
|
+
throw error;
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
function assertRegistryRecordMatchesRequest(request, record) {
|
|
3802
|
+
const expectedRemoteCapsuleId = `${request.host.domain}/${request.capsule.subname}`;
|
|
3803
|
+
const matches = record?.subname === request.capsule.subname && record?.domain === request.host.domain && (record?.remoteCapsuleId ?? expectedRemoteCapsuleId) === expectedRemoteCapsuleId;
|
|
3804
|
+
if (!matches) {
|
|
3805
|
+
throw helperError(
|
|
3806
|
+
"Hosted Capsule registry record does not match the release request.",
|
|
3807
|
+
"Rebind the local project or pass the correct Host profile and Capsule subname."
|
|
3808
|
+
);
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
async function mutateRegistryRecord(request, mutate) {
|
|
3812
|
+
return withRegistryLock(request, async () => {
|
|
3813
|
+
const registryRecordPath = registryPath(request);
|
|
3814
|
+
const record = JSON.parse(await readFile2(registryRecordPath, "utf8"));
|
|
3815
|
+
await writeRegistryRecordAtomic(registryRecordPath, mutate(record));
|
|
3816
|
+
});
|
|
3817
|
+
}
|
|
3818
|
+
async function writeRegistryRecordAtomic(registryRecordPath, record) {
|
|
3819
|
+
const tempPath = `${registryRecordPath}.tmp-${process.pid}-${Date.now()}`;
|
|
3820
|
+
try {
|
|
3821
|
+
await writeFile(tempPath, `${JSON.stringify(record, null, 2)}
|
|
3822
|
+
`);
|
|
3823
|
+
if (process.env.SPORADES_FAKE_REGISTRY_ATOMIC_WRITE_FAILURE === "1") {
|
|
3824
|
+
throw helperError(
|
|
3825
|
+
"Failed to write Hosted Capsule registry record.",
|
|
3826
|
+
"Check Host server disk permissions and free space, then retry the command."
|
|
3827
|
+
);
|
|
3828
|
+
}
|
|
3829
|
+
await rename(tempPath, registryRecordPath);
|
|
3830
|
+
} catch (error) {
|
|
3831
|
+
await rm2(tempPath, { force: true });
|
|
3832
|
+
throw error;
|
|
3833
|
+
}
|
|
3834
|
+
}
|
|
3835
|
+
async function withRegistryLock(request, fn) {
|
|
3836
|
+
const lockDir = registryLockPath(request);
|
|
3837
|
+
const timeoutMs = Number(process.env.SPORADES_REGISTRY_LOCK_TIMEOUT_MS ?? "5000");
|
|
3838
|
+
const startedAt = Date.now();
|
|
3839
|
+
while (true) {
|
|
3840
|
+
try {
|
|
3841
|
+
await mkdir(lockDir, { recursive: false });
|
|
3842
|
+
break;
|
|
3843
|
+
} catch (error) {
|
|
3844
|
+
if (errorDetails(error).code !== "EEXIST") {
|
|
3845
|
+
throw error;
|
|
3846
|
+
}
|
|
3847
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
3848
|
+
throw helperError(
|
|
3849
|
+
"Hosted domain registry is locked.",
|
|
3850
|
+
"Wait for the other Host server operation to finish, then retry the command."
|
|
3851
|
+
);
|
|
3852
|
+
}
|
|
3853
|
+
await delay(25);
|
|
3854
|
+
}
|
|
3855
|
+
}
|
|
3856
|
+
try {
|
|
3857
|
+
return await fn();
|
|
3858
|
+
} finally {
|
|
3859
|
+
await rm2(lockDir, { recursive: true, force: true });
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
function registryPath(request) {
|
|
3863
|
+
return path4.join(
|
|
3864
|
+
request.host.remoteRoot,
|
|
3865
|
+
"hosts",
|
|
3866
|
+
request.host.domain,
|
|
3867
|
+
"registry",
|
|
3868
|
+
"capsules",
|
|
3869
|
+
`${request.capsule.subname}.json`
|
|
3870
|
+
);
|
|
3871
|
+
}
|
|
3872
|
+
function registryLockPath(request) {
|
|
3873
|
+
return path4.join(request.host.remoteRoot, "hosts", request.host.domain, "registry", ".lock");
|
|
3874
|
+
}
|
|
3875
|
+
function capsuleData(request, lifecycle) {
|
|
3876
|
+
return {
|
|
3877
|
+
subname: request.capsule.subname,
|
|
3878
|
+
domain: request.host.domain,
|
|
3879
|
+
hostedUrl: lifecycle.hostedUrl,
|
|
3880
|
+
remoteCapsuleId: lifecycle.remoteCapsuleId
|
|
3881
|
+
};
|
|
3882
|
+
}
|
|
3883
|
+
function formatMount(mount) {
|
|
3884
|
+
const mode = mount.mode === "ro" ? ":ro" : mount.mode === "rw" ? ":rw" : "";
|
|
3885
|
+
return `${mount.host}:${mount.container}${mode}`;
|
|
3886
|
+
}
|
|
3887
|
+
async function pathExists(filePath) {
|
|
3888
|
+
try {
|
|
3889
|
+
await access(filePath);
|
|
3890
|
+
return true;
|
|
3891
|
+
} catch {
|
|
3892
|
+
return false;
|
|
3893
|
+
}
|
|
3894
|
+
}
|
|
3895
|
+
function pathExistsSync(filePath) {
|
|
3896
|
+
try {
|
|
3897
|
+
statSync(filePath);
|
|
3898
|
+
return true;
|
|
3899
|
+
} catch {
|
|
3900
|
+
return false;
|
|
3901
|
+
}
|
|
3902
|
+
}
|
|
3903
|
+
async function pathReadable(filePath) {
|
|
3904
|
+
if (!filePath) {
|
|
3905
|
+
return false;
|
|
3906
|
+
}
|
|
3907
|
+
try {
|
|
3908
|
+
await access(filePath, fsConstants.R_OK);
|
|
3909
|
+
return true;
|
|
3910
|
+
} catch {
|
|
3911
|
+
return false;
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
async function readManagedCaddyAccessLog(logs) {
|
|
3915
|
+
const readable = await pathReadable(logs.file);
|
|
3916
|
+
if (!readable) {
|
|
3917
|
+
if (logs.explicitFile) {
|
|
3918
|
+
throw helperError(
|
|
3919
|
+
"Host server Caddy combined logs are unavailable.",
|
|
3920
|
+
`Check that ${logs.file} exists and is readable by the Host helper, then retry \`sporades host logs\`.`
|
|
3921
|
+
);
|
|
3922
|
+
}
|
|
3923
|
+
return null;
|
|
3924
|
+
}
|
|
3925
|
+
const contents = await readFile2(logs.file, "utf8");
|
|
3926
|
+
return lastLogEntries(contents, logs.lines);
|
|
3927
|
+
}
|
|
3928
|
+
function readCaddyJournalLogs(logs) {
|
|
3929
|
+
const result = spawnSync2("journalctl", ["-u", "caddy", "-n", String(logs.lines), "--no-pager", "-o", "cat"], {
|
|
3930
|
+
encoding: "utf8"
|
|
3931
|
+
});
|
|
3932
|
+
if (result.error || result.status !== 0) {
|
|
3933
|
+
return null;
|
|
3934
|
+
}
|
|
3935
|
+
return lastLogEntries(result.stdout ?? "", logs.lines);
|
|
3936
|
+
}
|
|
3937
|
+
function readDockerStreamLogs(logs) {
|
|
3938
|
+
const result = spawnSync2("docker", ["logs", "--tail", String(logs.lines), logs.container.name], {
|
|
3939
|
+
encoding: "utf8"
|
|
3940
|
+
});
|
|
3941
|
+
if (result.error || result.status !== 0) {
|
|
3942
|
+
throw helperError(
|
|
3943
|
+
"Hosted Capsule container logs are unavailable.",
|
|
3944
|
+
`Check that Docker container ${logs.container.name} still exists, then retry \`sporades host logs ${logs.source}\`.`
|
|
3945
|
+
);
|
|
3946
|
+
}
|
|
3947
|
+
return lastLogEntries(logs.source === "stdout" ? result.stdout : result.stderr, logs.lines);
|
|
3948
|
+
}
|
|
3949
|
+
function lastLogEntries(contents, lines) {
|
|
3950
|
+
return String(contents ?? "").split(/\r?\n/).filter((line) => line.length > 0).slice(-lines);
|
|
3951
|
+
}
|
|
3952
|
+
function unavailableCaddyLogsError(request) {
|
|
3953
|
+
return helperError(
|
|
3954
|
+
"Host server Caddy combined logs are unavailable.",
|
|
3955
|
+
`Run \`sporades host bootstrap --host ${request.host.alias}\` and check Caddy on the Host server.`
|
|
3956
|
+
);
|
|
3957
|
+
}
|
|
3958
|
+
function unavailableCapsuleHttpLogsError(logs) {
|
|
3959
|
+
return helperError(
|
|
3960
|
+
"Hosted Capsule HTTP logs are unavailable.",
|
|
3961
|
+
`Check that ${logs.file} exists and is readable, then retry \`sporades host logs http --subname ${logs.subname}\`.`
|
|
3962
|
+
);
|
|
3963
|
+
}
|
|
3964
|
+
function trimForHint(value) {
|
|
3965
|
+
const trimmed = String(value ?? "").trim();
|
|
3966
|
+
return trimmed || "no stderr output";
|
|
3967
|
+
}
|
|
3968
|
+
function escapeRegExp(value) {
|
|
3969
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3970
|
+
}
|
|
3971
|
+
function createHostedContainerName(domain, subname) {
|
|
3972
|
+
return `sporades-${domain.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase()}-${subname}`;
|
|
3973
|
+
}
|
|
3974
|
+
function defaultCaddyAccessLogPath(remoteRoot) {
|
|
3975
|
+
return path4.join(remoteRoot, "caddy", "logs", "access.log");
|
|
3976
|
+
}
|
|
3977
|
+
function defaultCapsuleHttpLogPath(remoteRoot, domain, subname) {
|
|
3978
|
+
return path4.join(remoteRoot, "hosts", domain, "capsules", subname, "logs", "http.log");
|
|
3979
|
+
}
|
|
3980
|
+
async function assertRollbackReleaseFiles(request, releaseDirectory) {
|
|
3981
|
+
const requiredFiles = ["server.mjs", "client.js", "index.html", "sporades.json"];
|
|
3982
|
+
for (const file of requiredFiles) {
|
|
3983
|
+
if (!await pathReadable(path4.join(releaseDirectory, file))) {
|
|
3984
|
+
throw helperError(
|
|
3985
|
+
"Hosted Capsule release files are missing.",
|
|
3986
|
+
`The recorded release cannot be started from ${releaseDirectory}. Push a new release or choose another release from \`sporades host releases ${request.capsule.subname} --host ${request.host.alias} --json\`.`
|
|
3987
|
+
);
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3990
|
+
}
|
|
3991
|
+
async function verifyRegisteredCapsule(request, purpose = "push") {
|
|
3992
|
+
const record = await readRegistryRecordForCapsule(request, purpose);
|
|
3993
|
+
assertRegistryRecordMatchesRequest(request, record);
|
|
3994
|
+
if (record.status === "unregistered") {
|
|
3995
|
+
throw helperError(
|
|
3996
|
+
"Hosted Capsule is unregistered.",
|
|
3997
|
+
`Run \`sporades host register ${request.capsule.subname} --host ${request.host.alias}\` before retrying this command.`
|
|
3998
|
+
);
|
|
3999
|
+
}
|
|
4000
|
+
return record;
|
|
4001
|
+
}
|