@heroiclands/package-build 0.5.0 → 0.6.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/CHANGELOG.md +86 -0
- package/README.md +238 -2
- package/bin/package-build.mjs +482 -49
- package/bin/report.mjs +13 -4
- package/config.mjs +380 -1
- package/container.mjs +699 -0
- package/coverage.mjs +626 -0
- package/e2e.mjs +876 -0
- package/index.mjs +12 -0
- package/package.json +34 -12
- package/templates.mjs +224 -0
- package/types/config.d.mts +129 -0
- package/types/container.d.mts +359 -0
- package/types/coverage.d.mts +182 -0
- package/types/e2e.d.mts +351 -0
- package/types/index.d.mts +4 -0
- package/types/templates.d.mts +74 -0
package/container.mjs
ADDED
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Running a built package inside a Foundry VTT container.
|
|
16
|
+
*
|
|
17
|
+
* **The seam is the deploy's own.** `package-build deploy <stage>` installs a
|
|
18
|
+
* staged package into `FOUNDRYVTT_<STAGE>_DATA`; this module bind-mounts that
|
|
19
|
+
* same directory at `/data` and serves it. Nothing about the destination is
|
|
20
|
+
* restated — running Foundry against what was just deployed is the next step
|
|
21
|
+
* from one variable.
|
|
22
|
+
*
|
|
23
|
+
* The container runs the community `felddy/foundryvtt` image, which downloads
|
|
24
|
+
* the correct Foundry build for its platform at run time. A local Foundry
|
|
25
|
+
* install is deliberately **not** mounted: Foundry's Node distribution bundles
|
|
26
|
+
* per-platform native modules (`better-sqlite3`, `classic-level`), so a macOS
|
|
27
|
+
* install cannot run inside a Linux container.
|
|
28
|
+
*
|
|
29
|
+
* Licensing and provisioning are left to the image. Every `FOUNDRY_*` and
|
|
30
|
+
* `CONTAINER_*` variable in the environment is passed through, so credentials,
|
|
31
|
+
* a timed `FOUNDRY_RELEASE_URL`, or a pre-seeded cache are all a matter of
|
|
32
|
+
* configuration rather than code. See https://hub.docker.com/r/felddy/foundryvtt.
|
|
33
|
+
*
|
|
34
|
+
* **The environment is baked in at create time.** `FOUNDRY_*` values are fixed
|
|
35
|
+
* when the container is first created; a plain `start` or `restart` does not
|
|
36
|
+
* pick up a change to one. `recreate` is what applies it.
|
|
37
|
+
*
|
|
38
|
+
* The rules are pure functions over data — what a stage resolves to, which
|
|
39
|
+
* build a run pins, the argument vector that follows. The functions that talk
|
|
40
|
+
* to `docker` or the filesystem are named for it.
|
|
41
|
+
*
|
|
42
|
+
* @module
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import fs from "node:fs";
|
|
46
|
+
import path from "node:path";
|
|
47
|
+
import process from "node:process";
|
|
48
|
+
import { spawnSync } from "node:child_process";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Host port each conventional stage publishes, chosen so they can run at once.
|
|
52
|
+
*
|
|
53
|
+
* A stage a repository adds of its own declares its port in
|
|
54
|
+
* `packageBuild.container.stages`; these four need no entry because every
|
|
55
|
+
* HeroicLands package deploys to the same four.
|
|
56
|
+
*/
|
|
57
|
+
export const DEFAULT_STAGE_PORTS = Object.freeze({
|
|
58
|
+
dev: 30000,
|
|
59
|
+
qa: 30001,
|
|
60
|
+
prod: 30002,
|
|
61
|
+
test: 30003,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** The port Foundry listens on inside the container. */
|
|
65
|
+
export const CONTAINER_PORT = 30000;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Where a host-provided download cache is mounted.
|
|
69
|
+
*
|
|
70
|
+
* A dedicated mount point rather than a subpath of `/data` keeps the cache
|
|
71
|
+
* independent of the data root, so one cache can serve every stage.
|
|
72
|
+
*/
|
|
73
|
+
export const CACHE_MOUNT = "/container_cache";
|
|
74
|
+
|
|
75
|
+
/** What `package-build container` can be asked to do. */
|
|
76
|
+
export const CONTAINER_ACTIONS = Object.freeze([
|
|
77
|
+
"start",
|
|
78
|
+
"stop",
|
|
79
|
+
"restart",
|
|
80
|
+
"recreate",
|
|
81
|
+
"rm",
|
|
82
|
+
"status",
|
|
83
|
+
"logs",
|
|
84
|
+
"pull",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The image tag used when the package claims no Foundry version at all.
|
|
89
|
+
*
|
|
90
|
+
* Deliberately not a major: a hard-coded `:14` here would be this package
|
|
91
|
+
* choosing a Foundry generation on every consumer's behalf, and would rot on
|
|
92
|
+
* the next one. A package that states a `compatibility.minimum` gets its own
|
|
93
|
+
* major; one that states nothing floats, visibly.
|
|
94
|
+
*/
|
|
95
|
+
const FLOATING_IMAGE = "felddy/foundryvtt:release";
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The environment variable naming a stage's Foundry data root.
|
|
99
|
+
*
|
|
100
|
+
* Derived rather than tabulated, so a repository that adds a stage of its own
|
|
101
|
+
* gets the variable without this package learning its name.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} stage - The stage name.
|
|
104
|
+
* @returns {string} The variable to read.
|
|
105
|
+
*/
|
|
106
|
+
export function dataEnvVar(stage) {
|
|
107
|
+
return `FOUNDRYVTT_${stage.toUpperCase()}_DATA`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The container name for a package's stage.
|
|
112
|
+
*
|
|
113
|
+
* Named after the package so two HeroicLands packages can run their own
|
|
114
|
+
* containers side by side, and stable so Foundry's signed licence — which is
|
|
115
|
+
* bound to the container hostname — survives a recreate.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} packageId - The Foundry package id.
|
|
118
|
+
* @param {string} stage - The stage name.
|
|
119
|
+
* @returns {string} The container name.
|
|
120
|
+
*/
|
|
121
|
+
export function containerName(packageId, stage) {
|
|
122
|
+
return `${packageId}-foundry-${stage}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A stage declared in `packageBuild.container.stages`.
|
|
127
|
+
*
|
|
128
|
+
* @typedef {object} ContainerStage
|
|
129
|
+
* @property {number|null} port Host port to publish.
|
|
130
|
+
* @property {string|null} world World to auto-launch; `""` forces none.
|
|
131
|
+
* @property {string|null} version Exact Foundry build to pin.
|
|
132
|
+
*/
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The host port a stage publishes.
|
|
136
|
+
*
|
|
137
|
+
* `FOUNDRYVTT_<STAGE>_PORT` first, then the stage's declared port, then the
|
|
138
|
+
* conventional default.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} stage - The stage name.
|
|
141
|
+
* @param {object} [opts]
|
|
142
|
+
* @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
|
|
143
|
+
* @param {Record<string, ContainerStage>} [opts.stages] - Declared stages.
|
|
144
|
+
* @returns {number} The host port.
|
|
145
|
+
* @throws {Error} When the stage has no port from any source.
|
|
146
|
+
*/
|
|
147
|
+
export function resolveStagePort(
|
|
148
|
+
stage,
|
|
149
|
+
{ env = process.env, stages = {} } = {},
|
|
150
|
+
) {
|
|
151
|
+
const fromEnv = env[`FOUNDRYVTT_${stage.toUpperCase()}_PORT`]?.trim();
|
|
152
|
+
const port =
|
|
153
|
+
fromEnv ? Number(fromEnv)
|
|
154
|
+
: stages[stage]?.port != null ? stages[stage].port
|
|
155
|
+
: DEFAULT_STAGE_PORTS[stage];
|
|
156
|
+
if (!Number.isFinite(port)) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`No host port for stage '${stage}'. Declare it under ` +
|
|
159
|
+
`\`packageBuild.container.stages.${stage}.port\`, or set ` +
|
|
160
|
+
`FOUNDRYVTT_${stage.toUpperCase()}_PORT.`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return Number(port);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** An exact Foundry build: a major and a build number, nothing else. */
|
|
167
|
+
const EXACT_BUILD = /^\d+\.\d+$/;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The exact Foundry build a stage is pinned to, or `null` to float.
|
|
171
|
+
*
|
|
172
|
+
* `FOUNDRYVTT_<STAGE>_VERSION` wins — that is how a sweep runs against a build
|
|
173
|
+
* without touching committed configuration. Then the stage's own declared
|
|
174
|
+
* version. Then, **for the end-to-end stage only**, the package's
|
|
175
|
+
* `compatibility.minimum`.
|
|
176
|
+
*
|
|
177
|
+
* That last rule is the point. `compatibility.minimum` is a promise the
|
|
178
|
+
* manifest makes to every user, and a promise is only defended if something
|
|
179
|
+
* exercises it: a regression that breaks the floor while working on a newer
|
|
180
|
+
* build passes a suite run above the floor in silence. Deriving the pin from
|
|
181
|
+
* the claim means the evidence and the claim are the same number, and neither
|
|
182
|
+
* can drift from the other.
|
|
183
|
+
*
|
|
184
|
+
* A floor that names no build (`"14"`) cannot pin one, so the run floats on the
|
|
185
|
+
* major tag — visibly, rather than by pretending to a precision it lacks.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} stage - The stage name.
|
|
188
|
+
* @param {object} [opts]
|
|
189
|
+
* @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
|
|
190
|
+
* @param {Record<string, ContainerStage>} [opts.stages] - Declared stages.
|
|
191
|
+
* @param {string|null} [opts.compatibilityMinimum] - The claimed floor.
|
|
192
|
+
* @param {string} [opts.e2eStage] - Which stage the suite runs against.
|
|
193
|
+
* @returns {string|null} The exact build, or `null`.
|
|
194
|
+
*/
|
|
195
|
+
export function resolveFoundryVersion(
|
|
196
|
+
stage,
|
|
197
|
+
{
|
|
198
|
+
env = process.env,
|
|
199
|
+
stages = {},
|
|
200
|
+
compatibilityMinimum = null,
|
|
201
|
+
e2eStage = "test",
|
|
202
|
+
} = {},
|
|
203
|
+
) {
|
|
204
|
+
const fromEnv = env[`FOUNDRYVTT_${stage.toUpperCase()}_VERSION`]?.trim();
|
|
205
|
+
if (fromEnv) return fromEnv;
|
|
206
|
+
const declared = stages[stage]?.version;
|
|
207
|
+
if (declared) return declared;
|
|
208
|
+
if (stage !== e2eStage) return null;
|
|
209
|
+
return EXACT_BUILD.test(compatibilityMinimum ?? "") ?
|
|
210
|
+
/** @type {string} */ (compatibilityMinimum)
|
|
211
|
+
: null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The image a run uses.
|
|
216
|
+
*
|
|
217
|
+
* `FOUNDRYVTT_CONTAINER_IMAGE` wins, then `packageBuild.container.image`, then
|
|
218
|
+
* felddy's major tag for whichever version the package already implies — the
|
|
219
|
+
* pinned build, or failing that the compatibility floor. Only a package that
|
|
220
|
+
* claims neither floats.
|
|
221
|
+
*
|
|
222
|
+
* @param {object} [opts]
|
|
223
|
+
* @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
|
|
224
|
+
* @param {string|null} [opts.image] - The configured image.
|
|
225
|
+
* @param {string|null} [opts.version] - The pinned build, if any.
|
|
226
|
+
* @param {string|null} [opts.compatibilityMinimum] - The claimed floor.
|
|
227
|
+
* @returns {string} The image reference.
|
|
228
|
+
*/
|
|
229
|
+
export function resolveImage({
|
|
230
|
+
env = process.env,
|
|
231
|
+
image = null,
|
|
232
|
+
version = null,
|
|
233
|
+
compatibilityMinimum = null,
|
|
234
|
+
} = {}) {
|
|
235
|
+
const explicit = env.FOUNDRYVTT_CONTAINER_IMAGE?.trim();
|
|
236
|
+
if (explicit) return explicit;
|
|
237
|
+
if (image) return image;
|
|
238
|
+
const major = (version ?? compatibilityMinimum ?? "").split(".")[0];
|
|
239
|
+
return major ? `felddy/foundryvtt:${major}` : FLOATING_IMAGE;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The world a stage auto-launches.
|
|
244
|
+
*
|
|
245
|
+
* `null` leaves `FOUNDRY_WORLD` alone, so whatever the image was given decides.
|
|
246
|
+
* An empty string is a *declared* "never auto-launch" — the shape a stage whose
|
|
247
|
+
* world is managed by hand needs, and distinct from saying nothing.
|
|
248
|
+
*
|
|
249
|
+
* @param {string} stage - The stage name.
|
|
250
|
+
* @param {object} [opts]
|
|
251
|
+
* @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
|
|
252
|
+
* @param {Record<string, ContainerStage>} [opts.stages] - Declared stages.
|
|
253
|
+
* @returns {string|null} The world id, `""`, or `null`.
|
|
254
|
+
*/
|
|
255
|
+
export function resolveWorld(stage, { env = process.env, stages = {} } = {}) {
|
|
256
|
+
const fromEnv = env[`FOUNDRYVTT_${stage.toUpperCase()}_WORLD`];
|
|
257
|
+
if (fromEnv !== undefined) return fromEnv.trim();
|
|
258
|
+
const declared = stages[stage]?.world;
|
|
259
|
+
return declared === undefined || declared === null ?
|
|
260
|
+
null
|
|
261
|
+
: String(declared);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A stage's dedicated Foundry licence key, if it has one.
|
|
266
|
+
*
|
|
267
|
+
* Foundry is single-seat, so running a `dev` and a `test` container at once
|
|
268
|
+
* needs two keys. `FOUNDRYVTT_<STAGE>_LICENSE_KEY` dedicates one to a stage,
|
|
269
|
+
* overriding any global `FOUNDRY_LICENSE_KEY` passed through.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} stage - The stage name.
|
|
272
|
+
* @param {NodeJS.ProcessEnv} [env] - Environment to read.
|
|
273
|
+
* @returns {string|null} The key, or `null`.
|
|
274
|
+
*/
|
|
275
|
+
export function resolveLicenseKey(stage, env = process.env) {
|
|
276
|
+
return env[`FOUNDRYVTT_${stage.toUpperCase()}_LICENSE_KEY`]?.trim() || null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The Foundry data root for a stage, checked for what a bind mount needs.
|
|
281
|
+
*
|
|
282
|
+
* @param {string} stage - The stage name.
|
|
283
|
+
* @param {object} [opts]
|
|
284
|
+
* @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
|
|
285
|
+
* @returns {string} The local path.
|
|
286
|
+
* @throws {Error} When it is unset, or names a remote host.
|
|
287
|
+
*/
|
|
288
|
+
export function resolveDataRoot(stage, { env = process.env } = {}) {
|
|
289
|
+
const variable = dataEnvVar(stage);
|
|
290
|
+
const dataRoot = env[variable]?.trim() ?? "";
|
|
291
|
+
if (!dataRoot) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
`No data directory configured for stage '${stage}'. Set ` +
|
|
294
|
+
`${variable} — for example ${variable}="/path/to/foundryvtt/data".`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
// A remote SFTP target (`[user@]host:/path`) is a perfectly good deploy
|
|
298
|
+
// destination and an impossible bind mount.
|
|
299
|
+
const colon = dataRoot.indexOf(":");
|
|
300
|
+
const isWindowsPath = /^[A-Za-z]:[\\/]/.test(dataRoot);
|
|
301
|
+
if (!dataRoot.startsWith("/") && !isWindowsPath && colon > 0) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`${variable} is a remote target ('${dataRoot}'). A container needs ` +
|
|
304
|
+
`a local path to bind-mount.`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return dataRoot;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The image's own environment variables, as key/value pairs.
|
|
312
|
+
*
|
|
313
|
+
* `CONTAINER_CACHE` is deliberately withheld: it names a path *inside* the
|
|
314
|
+
* container, and {@link dockerRunArgs} sets it to match a mount it actually
|
|
315
|
+
* makes. A host path from a `.env` file would otherwise reach the image naming
|
|
316
|
+
* a directory that is not there.
|
|
317
|
+
*
|
|
318
|
+
* @param {NodeJS.ProcessEnv} [env] - Environment to read.
|
|
319
|
+
* @returns {[string, string][]} The pairs to pass through.
|
|
320
|
+
*/
|
|
321
|
+
export function passthroughEnv(env = process.env) {
|
|
322
|
+
const pairs = [];
|
|
323
|
+
for (const [key, value] of Object.entries(env)) {
|
|
324
|
+
if (value == null) continue;
|
|
325
|
+
const passes =
|
|
326
|
+
key.startsWith("FOUNDRY_") ||
|
|
327
|
+
(key.startsWith("CONTAINER_") && key !== "CONTAINER_CACHE");
|
|
328
|
+
if (passes) pairs.push([key, value]);
|
|
329
|
+
}
|
|
330
|
+
return pairs;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The full `docker run` argument vector for a stage's container.
|
|
335
|
+
*
|
|
336
|
+
* Order matters at the end: docker takes the **last** `-e` for a repeated key,
|
|
337
|
+
* so the per-stage version, world and licence are appended after the
|
|
338
|
+
* passthrough and win over anything it carried.
|
|
339
|
+
*
|
|
340
|
+
* @param {object} opts
|
|
341
|
+
* @param {string} opts.name - Container name.
|
|
342
|
+
* @param {string} opts.image - Image reference.
|
|
343
|
+
* @param {number} opts.port - Host port to publish.
|
|
344
|
+
* @param {string} opts.dataRoot - Host directory to mount at `/data`.
|
|
345
|
+
* @param {NodeJS.ProcessEnv} [opts.env] - Environment to pass through.
|
|
346
|
+
* @param {string|null} [opts.cacheDir] - Host download cache to mount.
|
|
347
|
+
* @param {string|null} [opts.version] - Exact build to pin.
|
|
348
|
+
* @param {string|null} [opts.world] - World to auto-launch; `""` forces none.
|
|
349
|
+
* @param {string|null} [opts.licenseKey] - Dedicated licence key.
|
|
350
|
+
* @returns {string[]} Arguments after `docker`.
|
|
351
|
+
*/
|
|
352
|
+
export function dockerRunArgs({
|
|
353
|
+
name,
|
|
354
|
+
image,
|
|
355
|
+
port,
|
|
356
|
+
dataRoot,
|
|
357
|
+
env = process.env,
|
|
358
|
+
cacheDir = null,
|
|
359
|
+
version = null,
|
|
360
|
+
world = null,
|
|
361
|
+
licenseKey = null,
|
|
362
|
+
}) {
|
|
363
|
+
const args = [
|
|
364
|
+
"run",
|
|
365
|
+
"--detach",
|
|
366
|
+
"--name",
|
|
367
|
+
name,
|
|
368
|
+
// Foundry binds a signed licence to the hostname. Without a stable one
|
|
369
|
+
// docker assigns a fresh container id each run and the licence reverts
|
|
370
|
+
// to "requires signature" on every recreate.
|
|
371
|
+
"--hostname",
|
|
372
|
+
name,
|
|
373
|
+
"--publish",
|
|
374
|
+
`${port}:${CONTAINER_PORT}`,
|
|
375
|
+
"--volume",
|
|
376
|
+
`${dataRoot}:/data`,
|
|
377
|
+
];
|
|
378
|
+
if (cacheDir) {
|
|
379
|
+
args.push(
|
|
380
|
+
"--volume",
|
|
381
|
+
`${cacheDir}:${CACHE_MOUNT}`,
|
|
382
|
+
"-e",
|
|
383
|
+
`CONTAINER_CACHE=${CACHE_MOUNT}`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
for (const [key, value] of passthroughEnv(env)) {
|
|
387
|
+
args.push("-e", `${key}=${value}`);
|
|
388
|
+
}
|
|
389
|
+
if (version) args.push("-e", `FOUNDRY_VERSION=${version}`);
|
|
390
|
+
if (world !== null) args.push("-e", `FOUNDRY_WORLD=${world}`);
|
|
391
|
+
if (licenseKey) args.push("-e", `FOUNDRY_LICENSE_KEY=${licenseKey}`);
|
|
392
|
+
args.push(image);
|
|
393
|
+
return args;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Run the `docker` CLI, inheriting stdio.
|
|
398
|
+
*
|
|
399
|
+
* @param {string[]} args - Arguments after `docker`.
|
|
400
|
+
* @returns {number} The exit status.
|
|
401
|
+
* @throws {Error} When `docker` is not on `PATH`.
|
|
402
|
+
*/
|
|
403
|
+
export function runDocker(args) {
|
|
404
|
+
const result = spawnSync("docker", args, { stdio: "inherit" });
|
|
405
|
+
if (result.error) {
|
|
406
|
+
if (
|
|
407
|
+
/** @type {NodeJS.ErrnoException} */ (result.error).code ===
|
|
408
|
+
"ENOENT"
|
|
409
|
+
) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
"docker was not found on PATH. Install Docker and make sure " +
|
|
412
|
+
"the `docker` CLI is available.",
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
throw result.error;
|
|
416
|
+
}
|
|
417
|
+
return result.status ?? 0;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Run the `docker` CLI and capture its output, tolerating failure.
|
|
422
|
+
*
|
|
423
|
+
* @param {string[]} args - Arguments after `docker`.
|
|
424
|
+
* @returns {string} Trimmed stdout, or `""` when the command failed.
|
|
425
|
+
*/
|
|
426
|
+
export function captureDocker(args) {
|
|
427
|
+
const result = spawnSync("docker", args, { encoding: "utf8" });
|
|
428
|
+
if (result.error || result.status !== 0) return "";
|
|
429
|
+
return (result.stdout ?? "").trim();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* @param {string} name - Container name.
|
|
434
|
+
* @param {boolean} [runningOnly] - Only count a running container.
|
|
435
|
+
* @returns {boolean} Whether a container with exactly this name is present.
|
|
436
|
+
*/
|
|
437
|
+
export function containerExists(name, runningOnly = false) {
|
|
438
|
+
const args = ["ps"];
|
|
439
|
+
if (!runningOnly) args.push("-a");
|
|
440
|
+
args.push("--filter", `name=^${name}$`, "--format", "{{.Names}}");
|
|
441
|
+
return captureDocker(args).split("\n").includes(name);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The HeroicLands-convention Foundry containers currently running.
|
|
446
|
+
*
|
|
447
|
+
* Used to warn about a single-seat licence clash before a run starts, which is
|
|
448
|
+
* why the filter is the shared `-foundry-` convention rather than one package's
|
|
449
|
+
* prefix: the clash is between *any* two licensed instances, not between two
|
|
450
|
+
* containers of the same package.
|
|
451
|
+
*
|
|
452
|
+
* @param {string} [except] - A container to omit from the list.
|
|
453
|
+
* @returns {string[]} The container names.
|
|
454
|
+
*/
|
|
455
|
+
export function runningFoundryContainers(except = "") {
|
|
456
|
+
return captureDocker([
|
|
457
|
+
"ps",
|
|
458
|
+
"--filter",
|
|
459
|
+
"name=-foundry-",
|
|
460
|
+
"--format",
|
|
461
|
+
"{{.Names}}",
|
|
462
|
+
])
|
|
463
|
+
.split("\n")
|
|
464
|
+
.filter((name) => name && name !== except);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Remove the data-root lock a container left behind when it did not shut down
|
|
469
|
+
* cleanly (`docker rm -f`, a crash, an OOM).
|
|
470
|
+
*
|
|
471
|
+
* Foundry then refuses to start with "already locked by another process",
|
|
472
|
+
* naming no owner — so a stale lock turns every later boot into a failure that
|
|
473
|
+
* reads like corruption rather than litter.
|
|
474
|
+
*
|
|
475
|
+
* **Only safe while the container is stopped**, which is exactly when every
|
|
476
|
+
* boot path here calls it: with nothing running against the data root, a lock
|
|
477
|
+
* present is by definition stale.
|
|
478
|
+
*
|
|
479
|
+
* @param {string} dataRoot - The Foundry user-data root.
|
|
480
|
+
* @param {(message: string) => void} [log] - Progress reporting.
|
|
481
|
+
*/
|
|
482
|
+
export function clearStaleLock(dataRoot, log = () => {}) {
|
|
483
|
+
const lock = path.join(dataRoot, "Config", "options.json.lock");
|
|
484
|
+
if (!fs.existsSync(lock)) return;
|
|
485
|
+
fs.rmSync(lock, { recursive: true, force: true });
|
|
486
|
+
log(`Cleared stale Foundry lock: ${lock}`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Everything a container action needs, resolved from configuration and the
|
|
491
|
+
* environment once.
|
|
492
|
+
*
|
|
493
|
+
* @typedef {object} ResolvedContainer
|
|
494
|
+
* @property {string} stage
|
|
495
|
+
* @property {string} name
|
|
496
|
+
* @property {number} port
|
|
497
|
+
* @property {string} image
|
|
498
|
+
* @property {string} url
|
|
499
|
+
* @property {string|null} version
|
|
500
|
+
* @property {string|null} world
|
|
501
|
+
* @property {string|null} licenseKey
|
|
502
|
+
* @property {string|null} cacheDir
|
|
503
|
+
*/
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Resolve a stage's container settings.
|
|
507
|
+
*
|
|
508
|
+
* @param {object} opts
|
|
509
|
+
* @param {string} opts.stage - The stage name.
|
|
510
|
+
* @param {object} opts.config - The resolved package-build configuration.
|
|
511
|
+
* @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
|
|
512
|
+
* @returns {ResolvedContainer} The resolved settings.
|
|
513
|
+
*/
|
|
514
|
+
export function resolveContainer({ stage, config, env = process.env }) {
|
|
515
|
+
const stages = config.containerStages;
|
|
516
|
+
const version = resolveFoundryVersion(stage, {
|
|
517
|
+
env,
|
|
518
|
+
stages,
|
|
519
|
+
compatibilityMinimum: config.compatibilityMinimum,
|
|
520
|
+
e2eStage: config.e2eStage,
|
|
521
|
+
});
|
|
522
|
+
const port = resolveStagePort(stage, { env, stages });
|
|
523
|
+
return {
|
|
524
|
+
stage,
|
|
525
|
+
name: containerName(config.packageId, stage),
|
|
526
|
+
port,
|
|
527
|
+
image: resolveImage({
|
|
528
|
+
env,
|
|
529
|
+
image: config.containerImage,
|
|
530
|
+
version,
|
|
531
|
+
compatibilityMinimum: config.compatibilityMinimum,
|
|
532
|
+
}),
|
|
533
|
+
url: `http://localhost:${port}`,
|
|
534
|
+
version,
|
|
535
|
+
world: resolveWorld(stage, { env, stages }),
|
|
536
|
+
licenseKey: resolveLicenseKey(stage, env),
|
|
537
|
+
cacheDir: env.FOUNDRYVTT_CACHE?.trim() || null,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Start a stage's container, creating it when it does not exist yet.
|
|
543
|
+
*
|
|
544
|
+
* @param {ResolvedContainer} container - Resolved settings.
|
|
545
|
+
* @param {object} opts
|
|
546
|
+
* @param {string} opts.dataRoot - Host directory to mount at `/data`.
|
|
547
|
+
* @param {NodeJS.ProcessEnv} [opts.env] - Environment to pass through.
|
|
548
|
+
* @param {(message: string) => void} [opts.log] - Progress reporting.
|
|
549
|
+
* @returns {number} The exit status.
|
|
550
|
+
*/
|
|
551
|
+
export function startContainer(
|
|
552
|
+
container,
|
|
553
|
+
{ dataRoot, env = process.env, log = () => {} },
|
|
554
|
+
) {
|
|
555
|
+
if (containerExists(container.name)) {
|
|
556
|
+
// Nothing is running against this data root — we are about to start it
|
|
557
|
+
// — so any lock present was left by a previous crash.
|
|
558
|
+
if (!containerExists(container.name, true))
|
|
559
|
+
clearStaleLock(dataRoot, log);
|
|
560
|
+
log(
|
|
561
|
+
`Starting existing container '${container.name}' → ${container.url}`,
|
|
562
|
+
);
|
|
563
|
+
return runDocker(["start", container.name]);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
clearStaleLock(dataRoot, log);
|
|
567
|
+
if (container.cacheDir && !fs.existsSync(container.cacheDir)) {
|
|
568
|
+
throw new Error(
|
|
569
|
+
`FOUNDRYVTT_CACHE directory does not exist: ${container.cacheDir}.`,
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
log(
|
|
573
|
+
`Creating container '${container.name}' from ${container.image}\n` +
|
|
574
|
+
` data: ${dataRoot}\n url: ${container.url}`,
|
|
575
|
+
);
|
|
576
|
+
const status = runDocker(
|
|
577
|
+
dockerRunArgs({
|
|
578
|
+
name: container.name,
|
|
579
|
+
image: container.image,
|
|
580
|
+
port: container.port,
|
|
581
|
+
dataRoot,
|
|
582
|
+
env,
|
|
583
|
+
cacheDir: container.cacheDir,
|
|
584
|
+
version: container.version,
|
|
585
|
+
world: container.world,
|
|
586
|
+
licenseKey: container.licenseKey,
|
|
587
|
+
}),
|
|
588
|
+
);
|
|
589
|
+
if (status === 0) {
|
|
590
|
+
log(
|
|
591
|
+
`Started. Open ${container.url} (a first run installs Foundry — ` +
|
|
592
|
+
`see \`container logs\`).`,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
return status;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Stop and remove a container, tolerating "not running" and "no such
|
|
600
|
+
* container".
|
|
601
|
+
*
|
|
602
|
+
* @param {string} name - Container name.
|
|
603
|
+
* @param {(message: string) => void} [log] - Progress reporting.
|
|
604
|
+
*/
|
|
605
|
+
export function removeContainer(name, log = () => {}) {
|
|
606
|
+
if (!containerExists(name)) {
|
|
607
|
+
log(`No container '${name}' to remove.`);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
runDocker(["stop", name]);
|
|
611
|
+
runDocker(["rm", name]);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Perform one container action for a stage.
|
|
616
|
+
*
|
|
617
|
+
* @param {object} opts
|
|
618
|
+
* @param {string} opts.action - One of {@link CONTAINER_ACTIONS}.
|
|
619
|
+
* @param {string} opts.stage - The stage name.
|
|
620
|
+
* @param {object} opts.config - The resolved package-build configuration.
|
|
621
|
+
* @param {NodeJS.ProcessEnv} [opts.env] - Environment to read.
|
|
622
|
+
* @param {(message: string) => void} [opts.log] - Progress reporting.
|
|
623
|
+
* @returns {number} The exit status.
|
|
624
|
+
* @throws {Error} On an unknown action, or a stage with no usable data root.
|
|
625
|
+
*/
|
|
626
|
+
export function containerAction({
|
|
627
|
+
action,
|
|
628
|
+
stage,
|
|
629
|
+
config,
|
|
630
|
+
env = process.env,
|
|
631
|
+
log = () => {},
|
|
632
|
+
}) {
|
|
633
|
+
if (!CONTAINER_ACTIONS.includes(action)) {
|
|
634
|
+
throw new Error(
|
|
635
|
+
`Invalid container action '${action}'. Valid actions are: ` +
|
|
636
|
+
`${CONTAINER_ACTIONS.join(", ")}.`,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
const container = resolveContainer({ stage, config, env });
|
|
640
|
+
|
|
641
|
+
switch (action) {
|
|
642
|
+
case "start": {
|
|
643
|
+
const dataRoot = requireDataRoot(stage, env);
|
|
644
|
+
return startContainer(container, { dataRoot, env, log });
|
|
645
|
+
}
|
|
646
|
+
case "stop":
|
|
647
|
+
return runDocker(["stop", container.name]);
|
|
648
|
+
case "restart": {
|
|
649
|
+
// Not `docker restart`: that leaves no window in which to sweep the
|
|
650
|
+
// lock, so a container that died holding one could never be
|
|
651
|
+
// restarted back into a working state.
|
|
652
|
+
const dataRoot = requireDataRoot(stage, env);
|
|
653
|
+
runDocker(["stop", container.name]);
|
|
654
|
+
clearStaleLock(dataRoot, log);
|
|
655
|
+
return runDocker(["start", container.name]);
|
|
656
|
+
}
|
|
657
|
+
case "recreate": {
|
|
658
|
+
// The image's environment is fixed at `docker run` time, so this is
|
|
659
|
+
// what applies a changed FOUNDRY_WORLD, licence or cache.
|
|
660
|
+
removeContainer(container.name, log);
|
|
661
|
+
const dataRoot = requireDataRoot(stage, env);
|
|
662
|
+
clearStaleLock(dataRoot, log);
|
|
663
|
+
return startContainer(container, { dataRoot, env, log });
|
|
664
|
+
}
|
|
665
|
+
case "rm":
|
|
666
|
+
removeContainer(container.name, log);
|
|
667
|
+
return 0;
|
|
668
|
+
case "status":
|
|
669
|
+
return runDocker([
|
|
670
|
+
"ps",
|
|
671
|
+
"-a",
|
|
672
|
+
"--filter",
|
|
673
|
+
`name=^${container.name}$`,
|
|
674
|
+
]);
|
|
675
|
+
case "logs":
|
|
676
|
+
return runDocker(["logs", "-f", container.name]);
|
|
677
|
+
default:
|
|
678
|
+
return runDocker(["pull", container.image]);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* The stage's data root, checked for existence as well as shape.
|
|
684
|
+
*
|
|
685
|
+
* @param {string} stage - The stage name.
|
|
686
|
+
* @param {NodeJS.ProcessEnv} env - Environment to read.
|
|
687
|
+
* @returns {string} The local path.
|
|
688
|
+
* @throws {Error} When it is unset, remote, or absent from disk.
|
|
689
|
+
*/
|
|
690
|
+
function requireDataRoot(stage, env) {
|
|
691
|
+
const dataRoot = resolveDataRoot(stage, { env });
|
|
692
|
+
if (!fs.existsSync(dataRoot)) {
|
|
693
|
+
throw new Error(
|
|
694
|
+
`Data directory does not exist: ${dataRoot} (from ` +
|
|
695
|
+
`${dataEnvVar(stage)}).`,
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
return dataRoot;
|
|
699
|
+
}
|