@saws/docker-service 2.0.0-beta.3 → 2.0.0-beta.4
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/dist/.tsbuildinfo +1 -0
- package/dist/DockerService.d.ts +135 -0
- package/dist/DockerService.js +577 -0
- package/dist/index.js +1 -0
- package/package.json +6 -3
- package/src/DockerService.ts +0 -819
- package/tsconfig.json +0 -8
- /package/{src/index.ts → dist/index.d.ts} +0 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ServiceDefinition, } from "@saws/core";
|
|
6
|
+
import { runLocal } from "@saws/core/utils/run-local";
|
|
7
|
+
import { shellQuote } from "@saws/core/utils/shell-quote";
|
|
8
|
+
export class DockerService extends ServiceDefinition {
|
|
9
|
+
host;
|
|
10
|
+
appDirectory;
|
|
11
|
+
network;
|
|
12
|
+
registry;
|
|
13
|
+
auth;
|
|
14
|
+
image;
|
|
15
|
+
dockerfile;
|
|
16
|
+
buildContext;
|
|
17
|
+
volumes;
|
|
18
|
+
ports;
|
|
19
|
+
command;
|
|
20
|
+
labels;
|
|
21
|
+
restart;
|
|
22
|
+
healthCheck;
|
|
23
|
+
serviceType = "docker";
|
|
24
|
+
devProcess;
|
|
25
|
+
devEnvironmentFile;
|
|
26
|
+
localRunAbortController = new AbortController();
|
|
27
|
+
activeEphemeralContainers = new Set();
|
|
28
|
+
localRegistryAuthenticated = false;
|
|
29
|
+
remoteRegistryAuthenticated = false;
|
|
30
|
+
constructor(config) {
|
|
31
|
+
super(config);
|
|
32
|
+
const hasImage = "image" in config && config.image != null;
|
|
33
|
+
const hasDockerfile = "dockerfile" in config && config.dockerfile != null;
|
|
34
|
+
if (hasImage && hasDockerfile) {
|
|
35
|
+
throw new Error(`Docker service "${config.name}" cannot configure both image and dockerfile`);
|
|
36
|
+
}
|
|
37
|
+
const buildsDockerfile = !hasImage;
|
|
38
|
+
if (buildsDockerfile &&
|
|
39
|
+
config.registry != null &&
|
|
40
|
+
config.registry.replace(/\/+$/, "").length === 0) {
|
|
41
|
+
throw new Error(`Docker service "${config.name}" registry cannot be empty`);
|
|
42
|
+
}
|
|
43
|
+
if (buildsDockerfile && config.auth != null && config.auth.username.trim().length === 0) {
|
|
44
|
+
throw new Error(`Docker service "${config.name}" registry auth username cannot be empty`);
|
|
45
|
+
}
|
|
46
|
+
this.host = config.host;
|
|
47
|
+
this.appDirectory = config.appDirectory ?? "/opt/saws";
|
|
48
|
+
this.network = config.network ?? "saws";
|
|
49
|
+
this.registry = config.registry?.replace(/\/+$/, "");
|
|
50
|
+
this.auth = config.auth;
|
|
51
|
+
if (hasImage) {
|
|
52
|
+
this.image = config.image;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
this.dockerfile = hasDockerfile ? config.dockerfile : path.join(config.name, "Dockerfile");
|
|
56
|
+
this.buildContext = hasDockerfile ? config.buildContext : config.name;
|
|
57
|
+
}
|
|
58
|
+
this.volumes = config.volumes ?? [];
|
|
59
|
+
this.ports = config.ports ?? [];
|
|
60
|
+
this.command = config.command ?? [];
|
|
61
|
+
this.labels = config.labels ?? {};
|
|
62
|
+
this.restart = config.restart;
|
|
63
|
+
this.healthCheck = config.healthCheck;
|
|
64
|
+
}
|
|
65
|
+
async dev() {
|
|
66
|
+
await super.dev();
|
|
67
|
+
const stage = "local";
|
|
68
|
+
await this.buildDockerfileImage(stage, false);
|
|
69
|
+
const config = await this.getDockerRunConfig(stage, false);
|
|
70
|
+
try {
|
|
71
|
+
this.devEnvironmentFile = await this.writeLocalEnvironmentFile(stage, config);
|
|
72
|
+
this.devProcess = await this.startLocalContainer(config);
|
|
73
|
+
this.observeDevProcess(this.devProcess);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
await this.removeDevEnvironmentFile();
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
await this.onContainerStarted(stage);
|
|
80
|
+
}
|
|
81
|
+
async deploy(stage) {
|
|
82
|
+
await super.deploy(stage);
|
|
83
|
+
await this.buildDockerfileImage(stage, true);
|
|
84
|
+
await this.pushDockerfileImage(stage);
|
|
85
|
+
const config = await this.getDockerRunConfig(stage, true);
|
|
86
|
+
config.configHash = this.getContainerConfigHash(config);
|
|
87
|
+
if (this.host == null) {
|
|
88
|
+
await this.runLocalDetachedContainer(stage, config);
|
|
89
|
+
await this.onContainerStarted(stage);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
await this.assertRemoteHostReady();
|
|
93
|
+
let environmentFile;
|
|
94
|
+
try {
|
|
95
|
+
environmentFile = await this.writeRemoteEnvironmentFile(stage, config);
|
|
96
|
+
await this.runRemoteContainer(stage, config);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
if (environmentFile != null) {
|
|
100
|
+
await this.removeRemoteRuntimeFile(environmentFile);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
await this.onContainerStarted(stage);
|
|
104
|
+
}
|
|
105
|
+
exit() {
|
|
106
|
+
super.exit();
|
|
107
|
+
this.localRunAbortController.abort();
|
|
108
|
+
this.devProcess?.kill();
|
|
109
|
+
this.devProcess = undefined;
|
|
110
|
+
this.removeActiveEphemeralContainers();
|
|
111
|
+
void this.removeDevEnvironmentFile();
|
|
112
|
+
}
|
|
113
|
+
getContainerName(stage) {
|
|
114
|
+
return `${stage}-${this.name}`.replaceAll("_", "-").toLowerCase();
|
|
115
|
+
}
|
|
116
|
+
async getContainerEnvironment(stage) {
|
|
117
|
+
return {
|
|
118
|
+
...(await this.getDependenciesEnvironmentVariables(stage)),
|
|
119
|
+
...(await this.getStageEnvironmentVariables(stage)),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
async getDockerRunConfig(stage, deploy) {
|
|
123
|
+
return {
|
|
124
|
+
name: this.getContainerName(stage),
|
|
125
|
+
image: this.getImage(stage, deploy),
|
|
126
|
+
pull: this.dockerfile == null || deploy,
|
|
127
|
+
network: this.getNetwork(stage),
|
|
128
|
+
env: await this.getContainerEnvironment(stage),
|
|
129
|
+
volumes: this.volumes,
|
|
130
|
+
ports: this.ports,
|
|
131
|
+
command: this.command,
|
|
132
|
+
restart: this.restart,
|
|
133
|
+
healthCheck: this.healthCheck,
|
|
134
|
+
labels: {
|
|
135
|
+
...this.labels,
|
|
136
|
+
"saws.service": this.name,
|
|
137
|
+
"saws.serviceType": this.serviceType,
|
|
138
|
+
"saws.stage": stage,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
async onContainerStarted(_stage) { }
|
|
143
|
+
getImage(stage, deploy) {
|
|
144
|
+
if (this.image != null)
|
|
145
|
+
return this.image;
|
|
146
|
+
return this.getBuiltImageName(stage, deploy);
|
|
147
|
+
}
|
|
148
|
+
async buildDockerfileImage(stage, deploy) {
|
|
149
|
+
if (this.dockerfile == null)
|
|
150
|
+
return;
|
|
151
|
+
await this.buildImage(this.getImage(stage, deploy), deploy && this.host != null ? this.host.platform : undefined);
|
|
152
|
+
}
|
|
153
|
+
async pushDockerfileImage(stage) {
|
|
154
|
+
if (this.dockerfile == null || this.host == null)
|
|
155
|
+
return;
|
|
156
|
+
await this.pushImage(stage, this.getImage(stage, true));
|
|
157
|
+
}
|
|
158
|
+
getNetwork(stage) {
|
|
159
|
+
return `${this.network}-${stage}`;
|
|
160
|
+
}
|
|
161
|
+
getAppDirectory(stage) {
|
|
162
|
+
return path.posix.join(this.appDirectory, stage);
|
|
163
|
+
}
|
|
164
|
+
getBuiltImageName(stage, deploy) {
|
|
165
|
+
const repository = `${stage}-${this.name}`
|
|
166
|
+
.toLowerCase()
|
|
167
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
168
|
+
.replace(/^[._-]+|[._-]+$/g, "");
|
|
169
|
+
if (repository.length === 0) {
|
|
170
|
+
throw new Error(`Cannot derive a Docker image name for service "${this.name}"`);
|
|
171
|
+
}
|
|
172
|
+
if (deploy && this.host != null) {
|
|
173
|
+
if (this.registry == null) {
|
|
174
|
+
throw new Error(`Docker service "${this.name}" uses a Dockerfile and remote deploy, but no registry is configured`);
|
|
175
|
+
}
|
|
176
|
+
return `${this.registry}/${repository}:latest`;
|
|
177
|
+
}
|
|
178
|
+
return `saws-${repository}:latest`;
|
|
179
|
+
}
|
|
180
|
+
async buildImage(image, platform) {
|
|
181
|
+
const dockerfile = path.resolve(this.dockerfile);
|
|
182
|
+
const buildContext = path.resolve(this.buildContext ?? path.dirname(this.dockerfile));
|
|
183
|
+
await runLocal([
|
|
184
|
+
"docker build",
|
|
185
|
+
...(platform == null ? [] : [`--platform ${shellQuote(platform)}`]),
|
|
186
|
+
`-f ${shellQuote(dockerfile)}`,
|
|
187
|
+
`-t ${shellQuote(image)}`,
|
|
188
|
+
shellQuote(buildContext),
|
|
189
|
+
].join(" "), this.getLocalRunOptions());
|
|
190
|
+
}
|
|
191
|
+
async pushImage(stage, image) {
|
|
192
|
+
await this.authenticateLocalRegistry(stage);
|
|
193
|
+
await runLocal(`docker push ${shellQuote(image)}`, this.getLocalRunOptions());
|
|
194
|
+
}
|
|
195
|
+
async authenticateLocalRegistry(stage) {
|
|
196
|
+
if (this.registry == null || this.auth == null || this.localRegistryAuthenticated)
|
|
197
|
+
return;
|
|
198
|
+
await runLocal([
|
|
199
|
+
"docker login",
|
|
200
|
+
shellQuote(this.getRegistryServer()),
|
|
201
|
+
`--username ${shellQuote(this.auth.username)}`,
|
|
202
|
+
"--password-stdin",
|
|
203
|
+
].join(" "), this.getLocalRunOptions({ input: `${await this.resolveRegistryPassword(stage)}\n` }));
|
|
204
|
+
this.localRegistryAuthenticated = true;
|
|
205
|
+
}
|
|
206
|
+
async authenticateRemoteRegistry(stage) {
|
|
207
|
+
if (this.registry == null || this.auth == null || this.remoteRegistryAuthenticated)
|
|
208
|
+
return;
|
|
209
|
+
await this.host.exec([
|
|
210
|
+
"docker login",
|
|
211
|
+
shellQuote(this.getRegistryServer()),
|
|
212
|
+
`--username ${shellQuote(this.auth.username)}`,
|
|
213
|
+
"--password-stdin",
|
|
214
|
+
].join(" "), { input: `${await this.resolveRegistryPassword(stage)}\n` });
|
|
215
|
+
this.remoteRegistryAuthenticated = true;
|
|
216
|
+
}
|
|
217
|
+
async resolveRegistryPassword(stage) {
|
|
218
|
+
return typeof this.auth.password === "string"
|
|
219
|
+
? this.auth.password
|
|
220
|
+
: this.auth.password.resolve({ stage });
|
|
221
|
+
}
|
|
222
|
+
getRegistryServer() {
|
|
223
|
+
return this.registry.split("/", 1)[0];
|
|
224
|
+
}
|
|
225
|
+
async prepareLocalNetwork(network, options = {}) {
|
|
226
|
+
await runLocal(`docker network inspect ${shellQuote(network)} >/dev/null 2>&1 || docker network create ${shellQuote(network)}`, this.getLocalRunOptions(options));
|
|
227
|
+
}
|
|
228
|
+
async prepareRemote(stage, network, dryRun) {
|
|
229
|
+
if (!dryRun) {
|
|
230
|
+
await this.authenticateRemoteRegistry(stage);
|
|
231
|
+
}
|
|
232
|
+
await this.host.exec(`mkdir -p ${shellQuote(this.getAppDirectory(stage))}`, { dryRun });
|
|
233
|
+
await this.host.exec(`docker network inspect ${shellQuote(network)} >/dev/null 2>&1 || docker network create ${shellQuote(network)}`, { dryRun });
|
|
234
|
+
}
|
|
235
|
+
async assertRemoteHostReady(dryRun) {
|
|
236
|
+
await this.host.assertReady({ dryRun });
|
|
237
|
+
}
|
|
238
|
+
async runLocalDetachedContainer(stage, config) {
|
|
239
|
+
await this.prepareLocalNetwork(config.network);
|
|
240
|
+
if (config.pull !== false) {
|
|
241
|
+
await runLocal(`docker pull ${shellQuote(config.image)}`, this.getLocalRunOptions());
|
|
242
|
+
}
|
|
243
|
+
await this.withLocalEnvironmentFile(stage, config, async () => {
|
|
244
|
+
await runLocal(`docker rm -f ${shellQuote(config.name)} >/dev/null 2>&1 || true`, this.getLocalRunOptions());
|
|
245
|
+
await runLocal(this.getDockerRunCommand({
|
|
246
|
+
...config,
|
|
247
|
+
labels: {
|
|
248
|
+
...config.labels,
|
|
249
|
+
"saws.configHash": config.configHash ?? this.getContainerConfigHash(config),
|
|
250
|
+
},
|
|
251
|
+
}, true), this.getLocalRunOptions());
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
async runEphemeralContainer(stage, config, options = {}) {
|
|
255
|
+
if (stage === "local" || this.host == null) {
|
|
256
|
+
await this.prepareLocalNetwork(config.network, {
|
|
257
|
+
dryRun: options.dryRun,
|
|
258
|
+
serviceName: options.logServiceName,
|
|
259
|
+
});
|
|
260
|
+
if (config.pull !== false) {
|
|
261
|
+
await runLocal(`docker pull ${shellQuote(config.image)}`, this.getLocalRunOptions({
|
|
262
|
+
dryRun: options.dryRun,
|
|
263
|
+
serviceName: options.logServiceName,
|
|
264
|
+
}));
|
|
265
|
+
}
|
|
266
|
+
await this.withLocalEnvironmentFile(stage, config, async () => {
|
|
267
|
+
await runLocal(`docker rm -f ${shellQuote(config.name)} >/dev/null 2>&1 || true`, this.getLocalRunOptions({
|
|
268
|
+
dryRun: options.dryRun,
|
|
269
|
+
serviceName: options.logServiceName,
|
|
270
|
+
}));
|
|
271
|
+
if (!options.dryRun)
|
|
272
|
+
this.activeEphemeralContainers.add(config.name);
|
|
273
|
+
try {
|
|
274
|
+
await runLocal(this.getDockerRunCommand(config, false, { remove: true, includeRestart: false }), this.getLocalRunOptions({
|
|
275
|
+
dryRun: options.dryRun,
|
|
276
|
+
serviceName: options.logServiceName,
|
|
277
|
+
}));
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
this.activeEphemeralContainers.delete(config.name);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
await this.assertRemoteHostReady(options.dryRun);
|
|
286
|
+
await this.prepareRemote(stage, config.network, options.dryRun);
|
|
287
|
+
if (config.pull !== false) {
|
|
288
|
+
await this.host.exec(`docker pull ${shellQuote(config.image)}`, { dryRun: options.dryRun });
|
|
289
|
+
}
|
|
290
|
+
let environmentFile;
|
|
291
|
+
try {
|
|
292
|
+
environmentFile = await this.writeRemoteEnvironmentFile(stage, config, options.dryRun);
|
|
293
|
+
await this.host.exec(`docker rm -f ${shellQuote(config.name)} >/dev/null 2>&1 || true`, {
|
|
294
|
+
dryRun: options.dryRun,
|
|
295
|
+
});
|
|
296
|
+
await this.host.exec(this.getDockerRunCommand(config, false, { remove: true, includeRestart: false }), { dryRun: options.dryRun });
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
if (environmentFile != null) {
|
|
300
|
+
await this.removeRemoteRuntimeFile(environmentFile, options.dryRun);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
async startLocalContainer(config) {
|
|
305
|
+
await this.prepareLocalNetwork(config.network);
|
|
306
|
+
if (config.pull !== false) {
|
|
307
|
+
await runLocal(`docker pull ${shellQuote(config.image)}`, this.getLocalRunOptions());
|
|
308
|
+
}
|
|
309
|
+
await runLocal(`docker rm -f ${shellQuote(config.name)} >/dev/null 2>&1 || true`, this.getLocalRunOptions());
|
|
310
|
+
const args = [
|
|
311
|
+
"run",
|
|
312
|
+
"--name",
|
|
313
|
+
config.name,
|
|
314
|
+
"--network",
|
|
315
|
+
config.network,
|
|
316
|
+
...(config.env == null
|
|
317
|
+
? []
|
|
318
|
+
: Object.entries(config.env).flatMap(([key, value]) => ["-e", `${key}=${value}`])),
|
|
319
|
+
...(config.envFiles ?? []).flatMap((envFile) => ["--env-file", envFile]),
|
|
320
|
+
...(config.volumes ?? []).flatMap((volume) => ["-v", volume]),
|
|
321
|
+
...(config.ports ?? []).flatMap((port) => ["-p", port]),
|
|
322
|
+
...Object.entries(config.labels ?? {}).flatMap(([key, value]) => [
|
|
323
|
+
"--label",
|
|
324
|
+
`${key}=${value}`,
|
|
325
|
+
]),
|
|
326
|
+
...this.getDockerHealthCheckArgs(config.healthCheck).flatMap((argument) => argument.flagOnly ? [argument.flag] : [argument.flag, argument.value]),
|
|
327
|
+
config.image,
|
|
328
|
+
...(config.command ?? []),
|
|
329
|
+
];
|
|
330
|
+
return spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
331
|
+
}
|
|
332
|
+
getLocalRunOptions(options = {}) {
|
|
333
|
+
const { serviceName, ...runOptions } = options;
|
|
334
|
+
return {
|
|
335
|
+
...runOptions,
|
|
336
|
+
logSink: this.getRuntimeLogSink(),
|
|
337
|
+
serviceName: serviceName ?? this.name,
|
|
338
|
+
signal: this.localRunAbortController.signal,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
async runRemoteContainer(stage, config) {
|
|
342
|
+
await this.prepareRemote(stage, config.network);
|
|
343
|
+
if (config.pull !== false) {
|
|
344
|
+
await this.host.exec(`docker pull ${shellQuote(config.image)}`);
|
|
345
|
+
}
|
|
346
|
+
const configHash = config.configHash ?? this.getContainerConfigHash(config);
|
|
347
|
+
const containerName = shellQuote(config.name);
|
|
348
|
+
const image = shellQuote(config.image);
|
|
349
|
+
const currentHash = `$(docker inspect --format ${shellQuote('{{index .Config.Labels "saws.configHash"}}')} ${containerName} 2>/dev/null || true)`;
|
|
350
|
+
const currentImage = `$(docker inspect --format ${shellQuote("{{.Image}}")} ${containerName} 2>/dev/null || true)`;
|
|
351
|
+
const desiredImage = `$(docker image inspect --format ${shellQuote("{{.Id}}")} ${image})`;
|
|
352
|
+
const isRunning = `$(docker inspect --format ${shellQuote("{{.State.Running}}")} ${containerName} 2>/dev/null || true)`;
|
|
353
|
+
await this.host.exec([
|
|
354
|
+
`if [ "${currentHash}" = ${shellQuote(configHash)} ] && [ "${currentImage}" = "${desiredImage}" ]; then`,
|
|
355
|
+
`if [ "${isRunning}" = "true" ]; then`,
|
|
356
|
+
`echo ${shellQuote(`Container ${config.name} is unchanged`)}`,
|
|
357
|
+
"else",
|
|
358
|
+
`docker start ${containerName}`,
|
|
359
|
+
"fi",
|
|
360
|
+
"else",
|
|
361
|
+
`docker rm -f ${containerName} >/dev/null 2>&1 || true`,
|
|
362
|
+
this.getDockerRunCommand({
|
|
363
|
+
...config,
|
|
364
|
+
labels: {
|
|
365
|
+
...config.labels,
|
|
366
|
+
"saws.configHash": configHash,
|
|
367
|
+
},
|
|
368
|
+
}, true),
|
|
369
|
+
"fi",
|
|
370
|
+
].join("\n"));
|
|
371
|
+
}
|
|
372
|
+
getDockerRunCommand(config, detached, options = {}) {
|
|
373
|
+
const envArgs = Object.entries(config.env ?? {})
|
|
374
|
+
.map(([key, value]) => `-e ${shellQuote(`${key}=${value}`)}`)
|
|
375
|
+
.join(" ");
|
|
376
|
+
const envFileArgs = (config.envFiles ?? [])
|
|
377
|
+
.map((envFile) => `--env-file ${shellQuote(envFile)}`)
|
|
378
|
+
.join(" ");
|
|
379
|
+
const volumeArgs = (config.volumes ?? []).map((volume) => `-v ${shellQuote(volume)}`).join(" ");
|
|
380
|
+
const portArgs = (config.ports ?? []).map((port) => `-p ${shellQuote(port)}`).join(" ");
|
|
381
|
+
const labelArgs = Object.entries(config.labels ?? {})
|
|
382
|
+
.map(([key, value]) => `--label ${shellQuote(`${key}=${value}`)}`)
|
|
383
|
+
.join(" ");
|
|
384
|
+
const healthCheckArgs = this.getDockerHealthCheckArgs(config.healthCheck)
|
|
385
|
+
.map((argument) => argument.flagOnly ? argument.flag : `${argument.flag} ${shellQuote(argument.value)}`)
|
|
386
|
+
.join(" ");
|
|
387
|
+
const command = (config.command ?? []).map((part) => shellQuote(part)).join(" ");
|
|
388
|
+
return [
|
|
389
|
+
"docker run",
|
|
390
|
+
options.remove ? "--rm" : "",
|
|
391
|
+
detached ? "-d" : "",
|
|
392
|
+
`--name ${shellQuote(config.name)}`,
|
|
393
|
+
`--network ${shellQuote(config.network)}`,
|
|
394
|
+
options.includeRestart === false
|
|
395
|
+
? ""
|
|
396
|
+
: `--restart ${shellQuote(config.restart ?? "unless-stopped")}`,
|
|
397
|
+
envArgs,
|
|
398
|
+
envFileArgs,
|
|
399
|
+
volumeArgs,
|
|
400
|
+
portArgs,
|
|
401
|
+
labelArgs,
|
|
402
|
+
healthCheckArgs,
|
|
403
|
+
shellQuote(config.image),
|
|
404
|
+
command,
|
|
405
|
+
]
|
|
406
|
+
.filter(Boolean)
|
|
407
|
+
.join(" ");
|
|
408
|
+
}
|
|
409
|
+
getDockerHealthCheckArgs(healthCheck) {
|
|
410
|
+
if (healthCheck == null)
|
|
411
|
+
return [];
|
|
412
|
+
if (healthCheck === false)
|
|
413
|
+
return [{ flag: "--no-healthcheck", flagOnly: true }];
|
|
414
|
+
if (healthCheck.command.length === 0) {
|
|
415
|
+
throw new Error("Docker health check command cannot be empty");
|
|
416
|
+
}
|
|
417
|
+
if (healthCheck.retries != null &&
|
|
418
|
+
(!Number.isInteger(healthCheck.retries) || healthCheck.retries < 1)) {
|
|
419
|
+
throw new Error("Docker health check retries must be a positive integer");
|
|
420
|
+
}
|
|
421
|
+
const args = [{ flag: "--health-cmd", flagOnly: false, value: healthCheck.command }];
|
|
422
|
+
for (const [flag, value] of [
|
|
423
|
+
["--health-interval", healthCheck.interval],
|
|
424
|
+
["--health-timeout", healthCheck.timeout],
|
|
425
|
+
["--health-start-period", healthCheck.startPeriod],
|
|
426
|
+
]) {
|
|
427
|
+
if (value == null)
|
|
428
|
+
continue;
|
|
429
|
+
if (!isDockerDuration(value)) {
|
|
430
|
+
throw new Error(`${flag.slice(2)} must be a positive Docker duration`);
|
|
431
|
+
}
|
|
432
|
+
args.push({ flag, flagOnly: false, value });
|
|
433
|
+
}
|
|
434
|
+
if (healthCheck.retries != null) {
|
|
435
|
+
args.push({ flag: "--health-retries", flagOnly: false, value: String(healthCheck.retries) });
|
|
436
|
+
}
|
|
437
|
+
return args;
|
|
438
|
+
}
|
|
439
|
+
getContainerConfigHash(config) {
|
|
440
|
+
const labels = { ...config.labels };
|
|
441
|
+
delete labels["saws.configHash"];
|
|
442
|
+
return createHash("sha256")
|
|
443
|
+
.update(JSON.stringify({
|
|
444
|
+
name: config.name,
|
|
445
|
+
image: config.image,
|
|
446
|
+
network: config.network,
|
|
447
|
+
environment: sortRecord(config.env),
|
|
448
|
+
envFiles: [...(config.envFiles ?? [])].sort(),
|
|
449
|
+
volumes: [...(config.volumes ?? [])].sort(),
|
|
450
|
+
ports: [...(config.ports ?? [])].sort(),
|
|
451
|
+
command: config.command ?? [],
|
|
452
|
+
labels: sortRecord(labels),
|
|
453
|
+
restart: config.restart ?? "unless-stopped",
|
|
454
|
+
healthCheck: config.healthCheck === false
|
|
455
|
+
? false
|
|
456
|
+
: config.healthCheck == null
|
|
457
|
+
? null
|
|
458
|
+
: {
|
|
459
|
+
command: config.healthCheck.command,
|
|
460
|
+
interval: config.healthCheck.interval,
|
|
461
|
+
timeout: config.healthCheck.timeout,
|
|
462
|
+
retries: config.healthCheck.retries,
|
|
463
|
+
startPeriod: config.healthCheck.startPeriod,
|
|
464
|
+
},
|
|
465
|
+
}))
|
|
466
|
+
.digest("hex");
|
|
467
|
+
}
|
|
468
|
+
async writeRemoteEnvironmentFile(stage, config, dryRun) {
|
|
469
|
+
const contents = serializeEnvironment(config.env);
|
|
470
|
+
if (contents == null)
|
|
471
|
+
return undefined;
|
|
472
|
+
const runtimeFile = await this.writeRemoteRuntimeFile(stage, `${this.name}/container.env`, contents, dryRun);
|
|
473
|
+
config.env = undefined;
|
|
474
|
+
config.envFiles = [...(config.envFiles ?? []), runtimeFile.remotePath];
|
|
475
|
+
return runtimeFile;
|
|
476
|
+
}
|
|
477
|
+
async writeLocalEnvironmentFile(stage, config) {
|
|
478
|
+
const contents = serializeEnvironment(config.env);
|
|
479
|
+
if (contents == null)
|
|
480
|
+
return undefined;
|
|
481
|
+
const localPath = await this.writeLocalRuntimeFile(stage, `${this.name}/container.env`, contents);
|
|
482
|
+
config.env = undefined;
|
|
483
|
+
config.envFiles = [...(config.envFiles ?? []), localPath];
|
|
484
|
+
return localPath;
|
|
485
|
+
}
|
|
486
|
+
async withLocalEnvironmentFile(stage, config, callback) {
|
|
487
|
+
const environmentFile = await this.writeLocalEnvironmentFile(stage, config);
|
|
488
|
+
try {
|
|
489
|
+
return await callback();
|
|
490
|
+
}
|
|
491
|
+
finally {
|
|
492
|
+
if (environmentFile != null) {
|
|
493
|
+
await rm(environmentFile, { force: true });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
async writeRemoteRuntimeFile(stage, relativePath, contents, dryRun) {
|
|
498
|
+
const localDir = path.resolve(".saws", "hosts", this.host.name, stage);
|
|
499
|
+
await mkdir(localDir, { recursive: true });
|
|
500
|
+
const localPath = path.join(localDir, relativePath);
|
|
501
|
+
await mkdir(path.dirname(localPath), { recursive: true });
|
|
502
|
+
await writeFile(localPath, contents, { mode: 0o600 });
|
|
503
|
+
const remotePath = path.posix.join(this.getAppDirectory(stage), relativePath);
|
|
504
|
+
await this.host.exec(`mkdir -p ${shellQuote(path.posix.dirname(remotePath))}`, { dryRun });
|
|
505
|
+
await this.host.copyFile(localPath, remotePath, { dryRun });
|
|
506
|
+
return { localPath, remotePath };
|
|
507
|
+
}
|
|
508
|
+
async removeRemoteRuntimeFile(runtimeFile, dryRun) {
|
|
509
|
+
await rm(runtimeFile.localPath, { force: true });
|
|
510
|
+
await this.host.exec(`rm -f ${shellQuote(runtimeFile.remotePath)}`, { dryRun });
|
|
511
|
+
}
|
|
512
|
+
async writeLocalRuntimeFile(stage, relativePath, contents) {
|
|
513
|
+
const localPath = path.resolve(".saws", "local", stage, relativePath);
|
|
514
|
+
await mkdir(path.dirname(localPath), { recursive: true });
|
|
515
|
+
await writeFile(localPath, contents, { mode: 0o600 });
|
|
516
|
+
return localPath;
|
|
517
|
+
}
|
|
518
|
+
observeDevProcess(process) {
|
|
519
|
+
process.stdout?.on("data", (chunk) => {
|
|
520
|
+
this.writeRuntimeLog(chunk.toString("utf8"), "stdout");
|
|
521
|
+
});
|
|
522
|
+
process.stderr?.on("data", (chunk) => {
|
|
523
|
+
this.writeRuntimeLog(chunk.toString("utf8"), "stderr");
|
|
524
|
+
});
|
|
525
|
+
process.once("error", (error) => {
|
|
526
|
+
this.writeRuntimeLog(`${error.stack ?? error.message}\n`, "stderr");
|
|
527
|
+
});
|
|
528
|
+
process.once("exit", (code, signal) => {
|
|
529
|
+
if (this.devProcess === process)
|
|
530
|
+
this.devProcess = undefined;
|
|
531
|
+
if (code !== 0 && signal !== "SIGTERM" && signal !== "SIGINT") {
|
|
532
|
+
this.writeRuntimeLog(`Docker container exited with code ${code ?? "unknown"}${signal == null ? "" : ` (${signal})`}\n`, "stderr");
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
getStdOut() {
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
getStdErr() {
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
async removeDevEnvironmentFile() {
|
|
543
|
+
if (this.devEnvironmentFile == null)
|
|
544
|
+
return;
|
|
545
|
+
const localPath = this.devEnvironmentFile;
|
|
546
|
+
this.devEnvironmentFile = undefined;
|
|
547
|
+
await rm(localPath, { force: true });
|
|
548
|
+
}
|
|
549
|
+
removeActiveEphemeralContainers() {
|
|
550
|
+
for (const container of this.activeEphemeralContainers) {
|
|
551
|
+
spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
|
|
552
|
+
}
|
|
553
|
+
this.activeEphemeralContainers.clear();
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
function serializeEnvironment(environment) {
|
|
557
|
+
const entries = Object.entries(environment ?? {});
|
|
558
|
+
if (entries.length === 0)
|
|
559
|
+
return undefined;
|
|
560
|
+
for (const [key, value] of entries) {
|
|
561
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
562
|
+
throw new Error(`Invalid Docker environment variable name: ${key}`);
|
|
563
|
+
}
|
|
564
|
+
if (value.includes("\n") || value.includes("\r")) {
|
|
565
|
+
throw new Error(`Docker environment variable ${key} contains a newline`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return `${entries.map(([key, value]) => `${key}=${value}`).join("\n")}\n`;
|
|
569
|
+
}
|
|
570
|
+
function sortRecord(record) {
|
|
571
|
+
return Object.fromEntries(Object.entries(record ?? {}).sort(([left], [right]) => left.localeCompare(right)));
|
|
572
|
+
}
|
|
573
|
+
function isDockerDuration(value) {
|
|
574
|
+
if (!/^(?:\d+(?:\.\d+)?(?:ns|us|µs|ms|s|m|h))+$/.test(value))
|
|
575
|
+
return false;
|
|
576
|
+
return [...value.matchAll(/(\d+(?:\.\d+)?)(?:ns|us|µs|ms|s|m|h)/g)].some((match) => Number(match[1]) > 0);
|
|
577
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./DockerService.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saws/docker-service",
|
|
3
|
-
"version": "2.0.0-beta.
|
|
3
|
+
"version": "2.0.0-beta.4",
|
|
4
4
|
"description": "",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"author": "",
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@saws/core": "2.0.0-beta.
|
|
17
|
-
}
|
|
16
|
+
"@saws/core": "2.0.0-beta.4"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"./dist"
|
|
20
|
+
]
|
|
18
21
|
}
|