@saws/docker-service 2.0.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@saws/docker-service",
3
+ "version": "2.0.0-beta.3",
4
+ "description": "",
5
+ "license": "ISC",
6
+ "author": "",
7
+ "type": "module",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "dependencies": {
16
+ "@saws/core": "2.0.0-beta.3"
17
+ }
18
+ }
@@ -0,0 +1,819 @@
1
+ import { createHash } from "node:crypto";
2
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
3
+ import { mkdir, rm, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import {
6
+ ServiceDefinition,
7
+ type Host,
8
+ type SecretReference,
9
+ type ServiceDefinitionConfig,
10
+ } from "@saws/core";
11
+ import { runLocal } from "@saws/core/utils/run-local";
12
+ import { shellQuote } from "@saws/core/utils/shell-quote";
13
+
14
+ export type DockerHealthCheckConfig = {
15
+ /** Command executed by Docker inside the container using CMD-SHELL. */
16
+ command: string;
17
+ /** Time between checks, expressed as a Docker duration such as "10s". */
18
+ interval?: string;
19
+ /** Maximum time one check may run, expressed as a Docker duration. */
20
+ timeout?: string;
21
+ /** Consecutive failures required before the container is unhealthy. */
22
+ retries?: number;
23
+ /** Startup grace period during which failures do not count. */
24
+ startPeriod?: string;
25
+ };
26
+
27
+ export type RestartConfig = "always" | "unless-stopped" | "no";
28
+
29
+ type ImageConfig = {
30
+ image: string;
31
+ };
32
+
33
+ type DockerFileConfig = {
34
+ dockerfile: string;
35
+ buildContext?: string;
36
+ };
37
+
38
+ type DefaultDockerFileConfig = {
39
+ image?: never;
40
+ dockerfile?: never;
41
+ buildContext?: never;
42
+ };
43
+
44
+ export type DockerRegistryAuthConfig = {
45
+ username: string;
46
+ password: string | SecretReference;
47
+ };
48
+
49
+ export type DockerRunConfig = {
50
+ name: string;
51
+ image: string;
52
+ network: string;
53
+ env?: Record<string, string>;
54
+ envFiles?: string[];
55
+ volumes?: string[];
56
+ ports?: string[];
57
+ command?: string[];
58
+ labels?: Record<string, string>;
59
+ restart?: RestartConfig;
60
+ healthCheck?: DockerHealthCheckConfig | false;
61
+ pull?: boolean;
62
+ configHash?: string;
63
+ };
64
+
65
+ export type RuntimeFile = {
66
+ localPath: string;
67
+ remotePath: string;
68
+ };
69
+
70
+ export type DockerServiceConfig = (ImageConfig | DockerFileConfig | DefaultDockerFileConfig) & {
71
+ host: Host;
72
+ appDirectory?: string;
73
+ network?: string;
74
+ registry?: string;
75
+ auth?: DockerRegistryAuthConfig;
76
+ volumes?: string[];
77
+ ports?: string[];
78
+ command?: string[];
79
+ labels?: Record<string, string>;
80
+ restart?: RestartConfig;
81
+ healthCheck?: DockerHealthCheckConfig | false;
82
+ } & ServiceDefinitionConfig;
83
+
84
+ export class DockerService extends ServiceDefinition {
85
+ readonly host: Host;
86
+ readonly appDirectory: string;
87
+ readonly network: string;
88
+ readonly registry?: string;
89
+ readonly auth?: DockerRegistryAuthConfig;
90
+ readonly image?: string;
91
+ readonly dockerfile?: string;
92
+ readonly buildContext?: string;
93
+ readonly volumes: string[];
94
+ readonly ports: string[];
95
+ readonly command: string[];
96
+ readonly labels: Record<string, string>;
97
+ readonly restart?: RestartConfig;
98
+ readonly healthCheck?: DockerHealthCheckConfig | false;
99
+ protected readonly serviceType: string = "docker";
100
+ protected devProcess?: ChildProcess;
101
+ private devEnvironmentFile?: string;
102
+ private readonly localRunAbortController = new AbortController();
103
+ private readonly activeEphemeralContainers = new Set<string>();
104
+ private localRegistryAuthenticated = false;
105
+ private remoteRegistryAuthenticated = false;
106
+
107
+ constructor(config: DockerServiceConfig) {
108
+ super(config);
109
+ const hasImage = "image" in config && config.image != null;
110
+ const hasDockerfile = "dockerfile" in config && config.dockerfile != null;
111
+ if (hasImage && hasDockerfile) {
112
+ throw new Error(`Docker service "${config.name}" cannot configure both image and dockerfile`);
113
+ }
114
+ const buildsDockerfile = !hasImage;
115
+ if (
116
+ buildsDockerfile &&
117
+ config.registry != null &&
118
+ config.registry.replace(/\/+$/, "").length === 0
119
+ ) {
120
+ throw new Error(`Docker service "${config.name}" registry cannot be empty`);
121
+ }
122
+ if (buildsDockerfile && config.auth != null && config.auth.username.trim().length === 0) {
123
+ throw new Error(`Docker service "${config.name}" registry auth username cannot be empty`);
124
+ }
125
+
126
+ this.host = config.host;
127
+ this.appDirectory = config.appDirectory ?? "/opt/saws";
128
+ this.network = config.network ?? "saws";
129
+ this.registry = config.registry?.replace(/\/+$/, "");
130
+ this.auth = config.auth;
131
+
132
+ if (hasImage) {
133
+ this.image = config.image;
134
+ } else {
135
+ this.dockerfile = hasDockerfile ? config.dockerfile : path.join(config.name, "Dockerfile");
136
+ this.buildContext = hasDockerfile ? config.buildContext : config.name;
137
+ }
138
+
139
+ this.volumes = config.volumes ?? [];
140
+ this.ports = config.ports ?? [];
141
+ this.command = config.command ?? [];
142
+ this.labels = config.labels ?? {};
143
+ this.restart = config.restart;
144
+ this.healthCheck = config.healthCheck;
145
+ }
146
+
147
+ override async dev() {
148
+ await super.dev();
149
+
150
+ const stage = "local";
151
+ await this.buildDockerfileImage(stage, false);
152
+ const config = await this.getDockerRunConfig(stage, false);
153
+
154
+ try {
155
+ this.devEnvironmentFile = await this.writeLocalEnvironmentFile(stage, config);
156
+ this.devProcess = await this.startLocalContainer(config);
157
+ this.observeDevProcess(this.devProcess);
158
+ } catch (error) {
159
+ await this.removeDevEnvironmentFile();
160
+ throw error;
161
+ }
162
+
163
+ await this.onContainerStarted(stage);
164
+ }
165
+
166
+ override async deploy(stage: string) {
167
+ await super.deploy(stage);
168
+ await this.buildDockerfileImage(stage, true);
169
+ await this.pushDockerfileImage(stage);
170
+ const config = await this.getDockerRunConfig(stage, true);
171
+ config.configHash = this.getContainerConfigHash(config);
172
+
173
+ if (this.host == null) {
174
+ await this.runLocalDetachedContainer(stage, config);
175
+ await this.onContainerStarted(stage);
176
+ return;
177
+ }
178
+
179
+ await this.assertRemoteHostReady();
180
+ let environmentFile: RuntimeFile | undefined;
181
+
182
+ try {
183
+ environmentFile = await this.writeRemoteEnvironmentFile(stage, config);
184
+ await this.runRemoteContainer(stage, config);
185
+ } finally {
186
+ if (environmentFile != null) {
187
+ await this.removeRemoteRuntimeFile(environmentFile);
188
+ }
189
+ }
190
+
191
+ await this.onContainerStarted(stage);
192
+ }
193
+
194
+ override exit() {
195
+ super.exit();
196
+ this.localRunAbortController.abort();
197
+ this.devProcess?.kill();
198
+ this.devProcess = undefined;
199
+ this.removeActiveEphemeralContainers();
200
+ void this.removeDevEnvironmentFile();
201
+ }
202
+
203
+ protected getContainerName(stage: string) {
204
+ return `${stage}-${this.name}`.replaceAll("_", "-").toLowerCase();
205
+ }
206
+
207
+ protected async getContainerEnvironment(stage: string): Promise<Record<string, string>> {
208
+ return {
209
+ ...(await this.getDependenciesEnvironmentVariables(stage)),
210
+ ...(await this.getStageEnvironmentVariables(stage)),
211
+ };
212
+ }
213
+
214
+ protected async getDockerRunConfig(stage: string, deploy: boolean): Promise<DockerRunConfig> {
215
+ return {
216
+ name: this.getContainerName(stage),
217
+ image: this.getImage(stage, deploy),
218
+ pull: this.dockerfile == null || deploy,
219
+ network: this.getNetwork(stage),
220
+ env: await this.getContainerEnvironment(stage),
221
+ volumes: this.volumes,
222
+ ports: this.ports,
223
+ command: this.command,
224
+ restart: this.restart,
225
+ healthCheck: this.healthCheck,
226
+ labels: {
227
+ ...this.labels,
228
+ "saws.service": this.name,
229
+ "saws.serviceType": this.serviceType,
230
+ "saws.stage": stage,
231
+ },
232
+ };
233
+ }
234
+
235
+ protected async onContainerStarted(_stage: string) {}
236
+
237
+ protected getImage(stage: string, deploy: boolean) {
238
+ if (this.image != null) return this.image;
239
+ return this.getBuiltImageName(stage, deploy);
240
+ }
241
+
242
+ protected async buildDockerfileImage(stage: string, deploy: boolean) {
243
+ if (this.dockerfile == null) return;
244
+
245
+ await this.buildImage(
246
+ this.getImage(stage, deploy),
247
+ deploy && this.host != null ? this.host.platform : undefined,
248
+ );
249
+ }
250
+
251
+ protected async pushDockerfileImage(stage: string) {
252
+ if (this.dockerfile == null || this.host == null) return;
253
+
254
+ await this.pushImage(stage, this.getImage(stage, true));
255
+ }
256
+
257
+ protected getNetwork(stage: string) {
258
+ return `${this.network}-${stage}`;
259
+ }
260
+
261
+ protected getAppDirectory(stage: string) {
262
+ return path.posix.join(this.appDirectory, stage);
263
+ }
264
+
265
+ protected getBuiltImageName(stage: string, deploy: boolean) {
266
+ const repository = `${stage}-${this.name}`
267
+ .toLowerCase()
268
+ .replace(/[^a-z0-9._-]+/g, "-")
269
+ .replace(/^[._-]+|[._-]+$/g, "");
270
+
271
+ if (repository.length === 0) {
272
+ throw new Error(`Cannot derive a Docker image name for service "${this.name}"`);
273
+ }
274
+
275
+ if (deploy && this.host != null) {
276
+ if (this.registry == null) {
277
+ throw new Error(
278
+ `Docker service "${this.name}" uses a Dockerfile and remote deploy, but no registry is configured`,
279
+ );
280
+ }
281
+ return `${this.registry}/${repository}:latest`;
282
+ }
283
+
284
+ return `saws-${repository}:latest`;
285
+ }
286
+
287
+ private async buildImage(image: string, platform?: string) {
288
+ const dockerfile = path.resolve(this.dockerfile!);
289
+ const buildContext = path.resolve(this.buildContext ?? path.dirname(this.dockerfile!));
290
+ await runLocal(
291
+ [
292
+ "docker build",
293
+ ...(platform == null ? [] : [`--platform ${shellQuote(platform)}`]),
294
+ `-f ${shellQuote(dockerfile)}`,
295
+ `-t ${shellQuote(image)}`,
296
+ shellQuote(buildContext),
297
+ ].join(" "),
298
+ this.getLocalRunOptions(),
299
+ );
300
+ }
301
+
302
+ protected async pushImage(stage: string, image: string) {
303
+ await this.authenticateLocalRegistry(stage);
304
+ await runLocal(`docker push ${shellQuote(image)}`, this.getLocalRunOptions());
305
+ }
306
+
307
+ private async authenticateLocalRegistry(stage: string) {
308
+ if (this.registry == null || this.auth == null || this.localRegistryAuthenticated) return;
309
+ await runLocal(
310
+ [
311
+ "docker login",
312
+ shellQuote(this.getRegistryServer()),
313
+ `--username ${shellQuote(this.auth.username)}`,
314
+ "--password-stdin",
315
+ ].join(" "),
316
+ this.getLocalRunOptions({ input: `${await this.resolveRegistryPassword(stage)}\n` }),
317
+ );
318
+ this.localRegistryAuthenticated = true;
319
+ }
320
+
321
+ private async authenticateRemoteRegistry(stage: string) {
322
+ if (this.registry == null || this.auth == null || this.remoteRegistryAuthenticated) return;
323
+ await this.host!.exec(
324
+ [
325
+ "docker login",
326
+ shellQuote(this.getRegistryServer()),
327
+ `--username ${shellQuote(this.auth.username)}`,
328
+ "--password-stdin",
329
+ ].join(" "),
330
+ { input: `${await this.resolveRegistryPassword(stage)}\n` },
331
+ );
332
+ this.remoteRegistryAuthenticated = true;
333
+ }
334
+
335
+ private async resolveRegistryPassword(stage: string) {
336
+ return typeof this.auth!.password === "string"
337
+ ? this.auth!.password
338
+ : this.auth!.password.resolve({ stage });
339
+ }
340
+
341
+ private getRegistryServer() {
342
+ return this.registry!.split("/", 1)[0]!;
343
+ }
344
+
345
+ private async prepareLocalNetwork(
346
+ network: string,
347
+ options: { dryRun?: boolean; serviceName?: string } = {},
348
+ ) {
349
+ await runLocal(
350
+ `docker network inspect ${shellQuote(network)} >/dev/null 2>&1 || docker network create ${shellQuote(network)}`,
351
+ this.getLocalRunOptions(options),
352
+ );
353
+ }
354
+
355
+ protected async prepareRemote(stage: string, network: string, dryRun?: boolean) {
356
+ if (!dryRun) {
357
+ await this.authenticateRemoteRegistry(stage);
358
+ }
359
+ await this.host!.exec(`mkdir -p ${shellQuote(this.getAppDirectory(stage))}`, { dryRun });
360
+ await this.host!.exec(
361
+ `docker network inspect ${shellQuote(network)} >/dev/null 2>&1 || docker network create ${shellQuote(network)}`,
362
+ { dryRun },
363
+ );
364
+ }
365
+
366
+ protected async assertRemoteHostReady(dryRun?: boolean) {
367
+ await this.host!.assertReady({ dryRun });
368
+ }
369
+
370
+ protected async runLocalDetachedContainer(stage: string, config: DockerRunConfig) {
371
+ await this.prepareLocalNetwork(config.network);
372
+ if (config.pull !== false) {
373
+ await runLocal(`docker pull ${shellQuote(config.image)}`, this.getLocalRunOptions());
374
+ }
375
+ await this.withLocalEnvironmentFile(stage, config, async () => {
376
+ await runLocal(
377
+ `docker rm -f ${shellQuote(config.name)} >/dev/null 2>&1 || true`,
378
+ this.getLocalRunOptions(),
379
+ );
380
+ await runLocal(
381
+ this.getDockerRunCommand(
382
+ {
383
+ ...config,
384
+ labels: {
385
+ ...config.labels,
386
+ "saws.configHash": config.configHash ?? this.getContainerConfigHash(config),
387
+ },
388
+ },
389
+ true,
390
+ ),
391
+ this.getLocalRunOptions(),
392
+ );
393
+ });
394
+ }
395
+
396
+ protected async runEphemeralContainer(
397
+ stage: string,
398
+ config: DockerRunConfig,
399
+ options: { dryRun?: boolean; logServiceName?: string } = {},
400
+ ) {
401
+ if (stage === "local" || this.host == null) {
402
+ await this.prepareLocalNetwork(config.network, {
403
+ dryRun: options.dryRun,
404
+ serviceName: options.logServiceName,
405
+ });
406
+ if (config.pull !== false) {
407
+ await runLocal(
408
+ `docker pull ${shellQuote(config.image)}`,
409
+ this.getLocalRunOptions({
410
+ dryRun: options.dryRun,
411
+ serviceName: options.logServiceName,
412
+ }),
413
+ );
414
+ }
415
+ await this.withLocalEnvironmentFile(stage, config, async () => {
416
+ await runLocal(
417
+ `docker rm -f ${shellQuote(config.name)} >/dev/null 2>&1 || true`,
418
+ this.getLocalRunOptions({
419
+ dryRun: options.dryRun,
420
+ serviceName: options.logServiceName,
421
+ }),
422
+ );
423
+ if (!options.dryRun) this.activeEphemeralContainers.add(config.name);
424
+ try {
425
+ await runLocal(
426
+ this.getDockerRunCommand(config, false, { remove: true, includeRestart: false }),
427
+ this.getLocalRunOptions({
428
+ dryRun: options.dryRun,
429
+ serviceName: options.logServiceName,
430
+ }),
431
+ );
432
+ } finally {
433
+ this.activeEphemeralContainers.delete(config.name);
434
+ }
435
+ });
436
+ return;
437
+ }
438
+
439
+ await this.assertRemoteHostReady(options.dryRun);
440
+ await this.prepareRemote(stage, config.network, options.dryRun);
441
+
442
+ if (config.pull !== false) {
443
+ await this.host.exec(`docker pull ${shellQuote(config.image)}`, { dryRun: options.dryRun });
444
+ }
445
+
446
+ let environmentFile: RuntimeFile | undefined;
447
+ try {
448
+ environmentFile = await this.writeRemoteEnvironmentFile(stage, config, options.dryRun);
449
+ await this.host.exec(`docker rm -f ${shellQuote(config.name)} >/dev/null 2>&1 || true`, {
450
+ dryRun: options.dryRun,
451
+ });
452
+ await this.host.exec(
453
+ this.getDockerRunCommand(config, false, { remove: true, includeRestart: false }),
454
+ { dryRun: options.dryRun },
455
+ );
456
+ } finally {
457
+ if (environmentFile != null) {
458
+ await this.removeRemoteRuntimeFile(environmentFile, options.dryRun);
459
+ }
460
+ }
461
+ }
462
+
463
+ private async startLocalContainer(config: DockerRunConfig): Promise<ChildProcess> {
464
+ await this.prepareLocalNetwork(config.network);
465
+
466
+ if (config.pull !== false) {
467
+ await runLocal(`docker pull ${shellQuote(config.image)}`, this.getLocalRunOptions());
468
+ }
469
+ await runLocal(
470
+ `docker rm -f ${shellQuote(config.name)} >/dev/null 2>&1 || true`,
471
+ this.getLocalRunOptions(),
472
+ );
473
+
474
+ const args = [
475
+ "run",
476
+ "--name",
477
+ config.name,
478
+ "--network",
479
+ config.network,
480
+ ...(config.env == null
481
+ ? []
482
+ : Object.entries(config.env).flatMap(([key, value]) => ["-e", `${key}=${value}`])),
483
+ ...(config.envFiles ?? []).flatMap((envFile) => ["--env-file", envFile]),
484
+ ...(config.volumes ?? []).flatMap((volume) => ["-v", volume]),
485
+ ...(config.ports ?? []).flatMap((port) => ["-p", port]),
486
+ ...Object.entries(config.labels ?? {}).flatMap(([key, value]) => [
487
+ "--label",
488
+ `${key}=${value}`,
489
+ ]),
490
+ ...this.getDockerHealthCheckArgs(config.healthCheck).flatMap((argument) =>
491
+ argument.flagOnly ? [argument.flag] : [argument.flag, argument.value],
492
+ ),
493
+ config.image,
494
+ ...(config.command ?? []),
495
+ ];
496
+
497
+ return spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
498
+ }
499
+
500
+ private getLocalRunOptions(
501
+ options: { dryRun?: boolean; input?: string; serviceName?: string } = {},
502
+ ) {
503
+ const { serviceName, ...runOptions } = options;
504
+ return {
505
+ ...runOptions,
506
+ logSink: this.getRuntimeLogSink(),
507
+ serviceName: serviceName ?? this.name,
508
+ signal: this.localRunAbortController.signal,
509
+ };
510
+ }
511
+
512
+ protected async runRemoteContainer(stage: string, config: DockerRunConfig) {
513
+ await this.prepareRemote(stage, config.network);
514
+
515
+ if (config.pull !== false) {
516
+ await this.host!.exec(`docker pull ${shellQuote(config.image)}`);
517
+ }
518
+
519
+ const configHash = config.configHash ?? this.getContainerConfigHash(config);
520
+ const containerName = shellQuote(config.name);
521
+ const image = shellQuote(config.image);
522
+ const currentHash = `$(docker inspect --format ${shellQuote('{{index .Config.Labels "saws.configHash"}}')} ${containerName} 2>/dev/null || true)`;
523
+ const currentImage = `$(docker inspect --format ${shellQuote("{{.Image}}")} ${containerName} 2>/dev/null || true)`;
524
+ const desiredImage = `$(docker image inspect --format ${shellQuote("{{.Id}}")} ${image})`;
525
+ const isRunning = `$(docker inspect --format ${shellQuote("{{.State.Running}}")} ${containerName} 2>/dev/null || true)`;
526
+
527
+ await this.host!.exec(
528
+ [
529
+ `if [ "${currentHash}" = ${shellQuote(configHash)} ] && [ "${currentImage}" = "${desiredImage}" ]; then`,
530
+ `if [ "${isRunning}" = "true" ]; then`,
531
+ `echo ${shellQuote(`Container ${config.name} is unchanged`)}`,
532
+ "else",
533
+ `docker start ${containerName}`,
534
+ "fi",
535
+ "else",
536
+ `docker rm -f ${containerName} >/dev/null 2>&1 || true`,
537
+ this.getDockerRunCommand(
538
+ {
539
+ ...config,
540
+ labels: {
541
+ ...config.labels,
542
+ "saws.configHash": configHash,
543
+ },
544
+ },
545
+ true,
546
+ ),
547
+ "fi",
548
+ ].join("\n"),
549
+ );
550
+ }
551
+
552
+ protected getDockerRunCommand(
553
+ config: DockerRunConfig,
554
+ detached: boolean,
555
+ options: { remove?: boolean; includeRestart?: boolean } = {},
556
+ ) {
557
+ const envArgs = Object.entries(config.env ?? {})
558
+ .map(([key, value]) => `-e ${shellQuote(`${key}=${value}`)}`)
559
+ .join(" ");
560
+ const envFileArgs = (config.envFiles ?? [])
561
+ .map((envFile) => `--env-file ${shellQuote(envFile)}`)
562
+ .join(" ");
563
+ const volumeArgs = (config.volumes ?? []).map((volume) => `-v ${shellQuote(volume)}`).join(" ");
564
+ const portArgs = (config.ports ?? []).map((port) => `-p ${shellQuote(port)}`).join(" ");
565
+ const labelArgs = Object.entries(config.labels ?? {})
566
+ .map(([key, value]) => `--label ${shellQuote(`${key}=${value}`)}`)
567
+ .join(" ");
568
+ const healthCheckArgs = this.getDockerHealthCheckArgs(config.healthCheck)
569
+ .map((argument) =>
570
+ argument.flagOnly ? argument.flag : `${argument.flag} ${shellQuote(argument.value)}`,
571
+ )
572
+ .join(" ");
573
+ const command = (config.command ?? []).map((part) => shellQuote(part)).join(" ");
574
+
575
+ return [
576
+ "docker run",
577
+ options.remove ? "--rm" : "",
578
+ detached ? "-d" : "",
579
+ `--name ${shellQuote(config.name)}`,
580
+ `--network ${shellQuote(config.network)}`,
581
+ options.includeRestart === false
582
+ ? ""
583
+ : `--restart ${shellQuote(config.restart ?? "unless-stopped")}`,
584
+ envArgs,
585
+ envFileArgs,
586
+ volumeArgs,
587
+ portArgs,
588
+ labelArgs,
589
+ healthCheckArgs,
590
+ shellQuote(config.image),
591
+ command,
592
+ ]
593
+ .filter(Boolean)
594
+ .join(" ");
595
+ }
596
+
597
+ private getDockerHealthCheckArgs(
598
+ healthCheck: DockerRunConfig["healthCheck"],
599
+ ): Array<{ flag: string; flagOnly: true } | { flag: string; flagOnly: false; value: string }> {
600
+ if (healthCheck == null) return [];
601
+ if (healthCheck === false) return [{ flag: "--no-healthcheck", flagOnly: true }];
602
+ if (healthCheck.command.length === 0) {
603
+ throw new Error("Docker health check command cannot be empty");
604
+ }
605
+ if (
606
+ healthCheck.retries != null &&
607
+ (!Number.isInteger(healthCheck.retries) || healthCheck.retries < 1)
608
+ ) {
609
+ throw new Error("Docker health check retries must be a positive integer");
610
+ }
611
+
612
+ const args: Array<
613
+ { flag: string; flagOnly: true } | { flag: string; flagOnly: false; value: string }
614
+ > = [{ flag: "--health-cmd", flagOnly: false, value: healthCheck.command }];
615
+
616
+ for (const [flag, value] of [
617
+ ["--health-interval", healthCheck.interval],
618
+ ["--health-timeout", healthCheck.timeout],
619
+ ["--health-start-period", healthCheck.startPeriod],
620
+ ] as const) {
621
+ if (value == null) continue;
622
+ if (!isDockerDuration(value)) {
623
+ throw new Error(`${flag.slice(2)} must be a positive Docker duration`);
624
+ }
625
+ args.push({ flag, flagOnly: false, value });
626
+ }
627
+
628
+ if (healthCheck.retries != null) {
629
+ args.push({ flag: "--health-retries", flagOnly: false, value: String(healthCheck.retries) });
630
+ }
631
+
632
+ return args;
633
+ }
634
+
635
+ protected getContainerConfigHash(config: DockerRunConfig) {
636
+ const labels = { ...config.labels };
637
+ delete labels["saws.configHash"];
638
+
639
+ return createHash("sha256")
640
+ .update(
641
+ JSON.stringify({
642
+ name: config.name,
643
+ image: config.image,
644
+ network: config.network,
645
+ environment: sortRecord(config.env),
646
+ envFiles: [...(config.envFiles ?? [])].sort(),
647
+ volumes: [...(config.volumes ?? [])].sort(),
648
+ ports: [...(config.ports ?? [])].sort(),
649
+ command: config.command ?? [],
650
+ labels: sortRecord(labels),
651
+ restart: config.restart ?? "unless-stopped",
652
+ healthCheck:
653
+ config.healthCheck === false
654
+ ? false
655
+ : config.healthCheck == null
656
+ ? null
657
+ : {
658
+ command: config.healthCheck.command,
659
+ interval: config.healthCheck.interval,
660
+ timeout: config.healthCheck.timeout,
661
+ retries: config.healthCheck.retries,
662
+ startPeriod: config.healthCheck.startPeriod,
663
+ },
664
+ }),
665
+ )
666
+ .digest("hex");
667
+ }
668
+
669
+ protected async writeRemoteEnvironmentFile(
670
+ stage: string,
671
+ config: DockerRunConfig,
672
+ dryRun?: boolean,
673
+ ) {
674
+ const contents = serializeEnvironment(config.env);
675
+ if (contents == null) return undefined;
676
+
677
+ const runtimeFile = await this.writeRemoteRuntimeFile(
678
+ stage,
679
+ `${this.name}/container.env`,
680
+ contents,
681
+ dryRun,
682
+ );
683
+ config.env = undefined;
684
+ config.envFiles = [...(config.envFiles ?? []), runtimeFile.remotePath];
685
+ return runtimeFile;
686
+ }
687
+
688
+ protected async writeLocalEnvironmentFile(stage: string, config: DockerRunConfig) {
689
+ const contents = serializeEnvironment(config.env);
690
+ if (contents == null) return undefined;
691
+
692
+ const localPath = await this.writeLocalRuntimeFile(
693
+ stage,
694
+ `${this.name}/container.env`,
695
+ contents,
696
+ );
697
+ config.env = undefined;
698
+ config.envFiles = [...(config.envFiles ?? []), localPath];
699
+ return localPath;
700
+ }
701
+
702
+ private async withLocalEnvironmentFile<T>(
703
+ stage: string,
704
+ config: DockerRunConfig,
705
+ callback: () => Promise<T>,
706
+ ) {
707
+ const environmentFile = await this.writeLocalEnvironmentFile(stage, config);
708
+ try {
709
+ return await callback();
710
+ } finally {
711
+ if (environmentFile != null) {
712
+ await rm(environmentFile, { force: true });
713
+ }
714
+ }
715
+ }
716
+
717
+ protected async writeRemoteRuntimeFile(
718
+ stage: string,
719
+ relativePath: string,
720
+ contents: string,
721
+ dryRun?: boolean,
722
+ ): Promise<RuntimeFile> {
723
+ const localDir = path.resolve(".saws", "hosts", this.host!.name, stage);
724
+ await mkdir(localDir, { recursive: true });
725
+
726
+ const localPath = path.join(localDir, relativePath);
727
+ await mkdir(path.dirname(localPath), { recursive: true });
728
+ await writeFile(localPath, contents, { mode: 0o600 });
729
+
730
+ const remotePath = path.posix.join(this.getAppDirectory(stage), relativePath);
731
+ await this.host!.exec(`mkdir -p ${shellQuote(path.posix.dirname(remotePath))}`, { dryRun });
732
+ await this.host!.copyFile(localPath, remotePath, { dryRun });
733
+ return { localPath, remotePath };
734
+ }
735
+
736
+ protected async removeRemoteRuntimeFile(runtimeFile: RuntimeFile, dryRun?: boolean) {
737
+ await rm(runtimeFile.localPath, { force: true });
738
+ await this.host!.exec(`rm -f ${shellQuote(runtimeFile.remotePath)}`, { dryRun });
739
+ }
740
+
741
+ private async writeLocalRuntimeFile(stage: string, relativePath: string, contents: string) {
742
+ const localPath = path.resolve(".saws", "local", stage, relativePath);
743
+ await mkdir(path.dirname(localPath), { recursive: true });
744
+ await writeFile(localPath, contents, { mode: 0o600 });
745
+ return localPath;
746
+ }
747
+
748
+ private observeDevProcess(process: ChildProcess) {
749
+ process.stdout?.on("data", (chunk: Buffer) => {
750
+ this.writeRuntimeLog(chunk.toString("utf8"), "stdout");
751
+ });
752
+ process.stderr?.on("data", (chunk: Buffer) => {
753
+ this.writeRuntimeLog(chunk.toString("utf8"), "stderr");
754
+ });
755
+ process.once("error", (error) => {
756
+ this.writeRuntimeLog(`${error.stack ?? error.message}\n`, "stderr");
757
+ });
758
+ process.once("exit", (code, signal) => {
759
+ if (this.devProcess === process) this.devProcess = undefined;
760
+ if (code !== 0 && signal !== "SIGTERM" && signal !== "SIGINT") {
761
+ this.writeRuntimeLog(
762
+ `Docker container exited with code ${code ?? "unknown"}${signal == null ? "" : ` (${signal})`}\n`,
763
+ "stderr",
764
+ );
765
+ }
766
+ });
767
+ }
768
+
769
+ override getStdOut() {
770
+ return null;
771
+ }
772
+
773
+ override getStdErr() {
774
+ return null;
775
+ }
776
+
777
+ private async removeDevEnvironmentFile() {
778
+ if (this.devEnvironmentFile == null) return;
779
+ const localPath = this.devEnvironmentFile;
780
+ this.devEnvironmentFile = undefined;
781
+ await rm(localPath, { force: true });
782
+ }
783
+
784
+ private removeActiveEphemeralContainers() {
785
+ for (const container of this.activeEphemeralContainers) {
786
+ spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" });
787
+ }
788
+ this.activeEphemeralContainers.clear();
789
+ }
790
+ }
791
+
792
+ function serializeEnvironment(environment?: Record<string, string>) {
793
+ const entries = Object.entries(environment ?? {});
794
+ if (entries.length === 0) return undefined;
795
+
796
+ for (const [key, value] of entries) {
797
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
798
+ throw new Error(`Invalid Docker environment variable name: ${key}`);
799
+ }
800
+ if (value.includes("\n") || value.includes("\r")) {
801
+ throw new Error(`Docker environment variable ${key} contains a newline`);
802
+ }
803
+ }
804
+
805
+ return `${entries.map(([key, value]) => `${key}=${value}`).join("\n")}\n`;
806
+ }
807
+
808
+ function sortRecord<T>(record: Record<string, T> | undefined) {
809
+ return Object.fromEntries(
810
+ Object.entries(record ?? {}).sort(([left], [right]) => left.localeCompare(right)),
811
+ );
812
+ }
813
+
814
+ function isDockerDuration(value: string) {
815
+ if (!/^(?:\d+(?:\.\d+)?(?:ns|us|µs|ms|s|m|h))+$/.test(value)) return false;
816
+ return [...value.matchAll(/(\d+(?:\.\d+)?)(?:ns|us|µs|ms|s|m|h)/g)].some(
817
+ (match) => Number(match[1]) > 0,
818
+ );
819
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./DockerService.js";
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig-node.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "tsBuildInfoFile": "./dist/.tsbuildinfo"
7
+ }
8
+ }