@alchemy.run/docker 2.0.0-beta.84 → 2.0.0-beta.86
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/README.md +16 -0
- package/lib/Bootstrap.d.ts.map +1 -1
- package/lib/Bootstrap.js +2 -1
- package/lib/Bootstrap.js.map +1 -1
- package/lib/BuildCache.d.ts +87 -0
- package/lib/BuildCache.d.ts.map +1 -0
- package/lib/BuildCache.js +39 -0
- package/lib/BuildCache.js.map +1 -0
- package/lib/BuildHash.d.ts +20 -3
- package/lib/BuildHash.d.ts.map +1 -1
- package/lib/BuildHash.js +60 -21
- package/lib/BuildHash.js.map +1 -1
- package/lib/BuildPlan.d.ts +202 -0
- package/lib/BuildPlan.d.ts.map +1 -0
- package/lib/BuildPlan.js +514 -0
- package/lib/BuildPlan.js.map +1 -0
- package/lib/Container.d.ts +122 -1
- package/lib/Container.d.ts.map +1 -1
- package/lib/Container.js +232 -4
- package/lib/Container.js.map +1 -1
- package/lib/Context.d.ts +20 -1
- package/lib/Context.d.ts.map +1 -1
- package/lib/Context.js +15 -0
- package/lib/Context.js.map +1 -1
- package/lib/Docker.d.ts +113 -1
- package/lib/Docker.d.ts.map +1 -1
- package/lib/Docker.js +50 -8
- package/lib/Docker.js.map +1 -1
- package/lib/Dockerfile.d.ts +0 -2
- package/lib/Dockerfile.d.ts.map +1 -1
- package/lib/Dockerfile.js.map +1 -1
- package/lib/DockerfileParser.d.ts +125 -0
- package/lib/DockerfileParser.d.ts.map +1 -0
- package/lib/DockerfileParser.js +1040 -0
- package/lib/DockerfileParser.js.map +1 -0
- package/lib/HealthcheckCommand.d.ts.map +1 -1
- package/lib/HealthcheckCommand.js.map +1 -1
- package/lib/Image.d.ts +69 -13
- package/lib/Image.d.ts.map +1 -1
- package/lib/Image.js +59 -9
- package/lib/Image.js.map +1 -1
- package/lib/ImageArchive.d.ts +41 -0
- package/lib/ImageArchive.d.ts.map +1 -0
- package/lib/ImageArchive.js +54 -0
- package/lib/ImageArchive.js.map +1 -0
- package/lib/ImageSource.d.ts +151 -8
- package/lib/ImageSource.d.ts.map +1 -1
- package/lib/ImageSource.js +247 -72
- package/lib/ImageSource.js.map +1 -1
- package/lib/Network.d.ts +175 -3
- package/lib/Network.d.ts.map +1 -1
- package/lib/Network.js +296 -8
- package/lib/Network.js.map +1 -1
- package/lib/Providers.d.ts.map +1 -1
- package/lib/Providers.js.map +1 -1
- package/lib/Registry.d.ts.map +1 -1
- package/lib/Registry.js.map +1 -1
- package/lib/RegistryError.d.ts.map +1 -1
- package/lib/RegistryError.js.map +1 -1
- package/lib/RemoteImage.d.ts.map +1 -1
- package/lib/RemoteImage.js +1 -1
- package/lib/RemoteImage.js.map +1 -1
- package/lib/Service.d.ts +24 -3
- package/lib/Service.d.ts.map +1 -1
- package/lib/Service.js +154 -12
- package/lib/Service.js.map +1 -1
- package/lib/ServiceImage.d.ts.map +1 -1
- package/lib/ServiceImage.js.map +1 -1
- package/lib/ShellWord.d.ts +28 -0
- package/lib/ShellWord.d.ts.map +1 -0
- package/lib/ShellWord.js +301 -0
- package/lib/ShellWord.js.map +1 -0
- package/lib/Swarm.d.ts.map +1 -1
- package/lib/Swarm.js.map +1 -1
- package/lib/Volume.d.ts +24 -1
- package/lib/Volume.d.ts.map +1 -1
- package/lib/Volume.js +16 -0
- package/lib/Volume.js.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js.map +1 -1
- package/package.json +9 -9
- package/src/Bootstrap.ts +2 -0
- package/src/BuildCache.ts +111 -0
- package/src/BuildHash.ts +85 -26
- package/src/BuildPlan.ts +758 -0
- package/src/Container.ts +361 -5
- package/src/Context.ts +27 -1
- package/src/Docker.ts +157 -9
- package/src/Dockerfile.ts +0 -2
- package/src/DockerfileParser.ts +1152 -0
- package/src/Image.ts +75 -15
- package/src/ImageArchive.ts +77 -0
- package/src/ImageSource.ts +391 -99
- package/src/Network.ts +432 -10
- package/src/RemoteImage.ts +1 -1
- package/src/Service.ts +246 -62
- package/src/ShellWord.ts +326 -0
- package/src/Volume.ts +33 -1
package/src/Container.ts
CHANGED
|
@@ -66,6 +66,57 @@ export interface ContainerProps {
|
|
|
66
66
|
networkMode?: Container.NetworkMode;
|
|
67
67
|
/** Linux capabilities to add, for example `SYS_ADMIN`. */
|
|
68
68
|
capAdd?: string[];
|
|
69
|
+
/** Linux capabilities to drop, for example `["ALL"]`. */
|
|
70
|
+
capDrop?: string[];
|
|
71
|
+
/** Hard memory limit in Docker's byte-suffix format, e.g. `"512m"`. */
|
|
72
|
+
memory?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Combined memory-plus-swap limit in Docker's byte-suffix format.
|
|
75
|
+
* Requires `memory`. Set to the same value to disable swap, or `"-1"`
|
|
76
|
+
* to allow unlimited swap (subject to the host).
|
|
77
|
+
*/
|
|
78
|
+
memorySwap?: string;
|
|
79
|
+
/** Soft memory limit under contention, e.g. `"256m"`. */
|
|
80
|
+
memoryReservation?: string;
|
|
81
|
+
/** CPU quota, in cores (e.g. `0.5` limits to half a CPU). */
|
|
82
|
+
cpus?: number;
|
|
83
|
+
/** Maximum number of processes and threads in the container. */
|
|
84
|
+
pidsLimit?: number;
|
|
85
|
+
/** Mount the container's root filesystem read-only. Writable paths need a volume or tmpfs. */
|
|
86
|
+
readOnlyRootFilesystem?: boolean;
|
|
87
|
+
/** Prevent processes from gaining additional privileges through exec. */
|
|
88
|
+
noNewPrivileges?: boolean;
|
|
89
|
+
/** Run Docker's init process to forward signals and reap child processes. */
|
|
90
|
+
init?: boolean;
|
|
91
|
+
/** Runtime user, e.g. `"1000:1000"` or `"app"`. Omit to keep the image's user. */
|
|
92
|
+
user?: string;
|
|
93
|
+
/**
|
|
94
|
+
* Seccomp JSON profile path on the Docker CLI host, or `"builtin"` to
|
|
95
|
+
* select Docker's default policy. `"unconfined"` explicitly disables it.
|
|
96
|
+
* Changes to a profile file's contents recreate the container.
|
|
97
|
+
*/
|
|
98
|
+
seccompProfile?: string;
|
|
99
|
+
/**
|
|
100
|
+
* Name of a preloaded AppArmor profile, e.g. `"docker-default"`.
|
|
101
|
+
* Requires AppArmor support on the target host. `"unconfined"` disables it.
|
|
102
|
+
*/
|
|
103
|
+
appArmorProfile?: string;
|
|
104
|
+
/** Docker user-namespace mode. `"host"` opts out of daemon user remapping. */
|
|
105
|
+
userNamespace?: string;
|
|
106
|
+
/** Supplementary Linux group names or numeric GIDs. */
|
|
107
|
+
groupAdd?: string[];
|
|
108
|
+
/** Per-process resource limits. `-1` represents unlimited. */
|
|
109
|
+
ulimits?: Record<string, Container.Ulimit>;
|
|
110
|
+
/** Docker log driver, e.g. `"local"`, `"json-file"`, or `"none"`. */
|
|
111
|
+
logDriver?: string;
|
|
112
|
+
/** Driver-specific settings, such as `{ "max-size": "10m", "max-file": "3" }`. */
|
|
113
|
+
logOptions?: Record<string, string>;
|
|
114
|
+
/** Relative OOM score adjustment on Linux, from -1000 to 1000. */
|
|
115
|
+
oomScoreAdjustment?: number;
|
|
116
|
+
/** Stop signal, e.g. `"SIGTERM"` or `"SIGINT"`. */
|
|
117
|
+
stopSignal?: string;
|
|
118
|
+
/** Writable in-memory mounts, including size and POSIX mode controls. */
|
|
119
|
+
tmpfs?: Container.TmpfsMount[];
|
|
69
120
|
/** Host devices to expose to the container. */
|
|
70
121
|
devices?: Container.DeviceMapping[];
|
|
71
122
|
/**
|
|
@@ -107,6 +158,20 @@ export declare namespace Container {
|
|
|
107
158
|
/** Mount read-only. @default false */
|
|
108
159
|
readOnly?: boolean;
|
|
109
160
|
}
|
|
161
|
+
interface TmpfsMount {
|
|
162
|
+
/** Absolute mount target in the container, e.g. `"/tmp"`. */
|
|
163
|
+
path: string;
|
|
164
|
+
/** Docker byte-suffix size, e.g. `"32m"`. Omit for the engine default. */
|
|
165
|
+
size?: string;
|
|
166
|
+
/** POSIX mode as a number, e.g. `0o1777` for a sticky world-writable directory. */
|
|
167
|
+
mode?: number;
|
|
168
|
+
}
|
|
169
|
+
interface Ulimit {
|
|
170
|
+
/** Process soft limit. `-1` means unlimited. */
|
|
171
|
+
soft: number;
|
|
172
|
+
/** Process hard limit. `-1` means unlimited. */
|
|
173
|
+
hard: number;
|
|
174
|
+
}
|
|
110
175
|
interface NetworkMapping {
|
|
111
176
|
/** Network name or ID. */
|
|
112
177
|
name: string;
|
|
@@ -170,9 +235,24 @@ export interface Container extends Resource<
|
|
|
170
235
|
devices?: Container.DeviceMapping[];
|
|
171
236
|
},
|
|
172
237
|
never,
|
|
173
|
-
Providers
|
|
238
|
+
Providers,
|
|
239
|
+
ContainerLookup
|
|
174
240
|
> {}
|
|
175
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Props for {@link Container.lookup}: find a container this stack
|
|
244
|
+
* doesn't manage by its name.
|
|
245
|
+
*/
|
|
246
|
+
export interface ContainerLookup {
|
|
247
|
+
/** Container name. */
|
|
248
|
+
name: string;
|
|
249
|
+
/**
|
|
250
|
+
* Docker context the engine is reached through.
|
|
251
|
+
* @default the current context
|
|
252
|
+
*/
|
|
253
|
+
context?: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
176
256
|
/**
|
|
177
257
|
* A Docker container managed through the active Docker context.
|
|
178
258
|
*
|
|
@@ -280,6 +360,39 @@ export interface Container extends Resource<
|
|
|
280
360
|
* ```
|
|
281
361
|
*
|
|
282
362
|
* ### Runtime Options
|
|
363
|
+
* **Example:** Contain a background worker
|
|
364
|
+
* ```typescript
|
|
365
|
+
* const worker = yield* Docker.Container("worker", {
|
|
366
|
+
* image: "oven/bun:1",
|
|
367
|
+
* memory: "512m",
|
|
368
|
+
* memorySwap: "512m",
|
|
369
|
+
* memoryReservation: "256m",
|
|
370
|
+
* cpus: 0.5,
|
|
371
|
+
* pidsLimit: 128,
|
|
372
|
+
* readOnlyRootFilesystem: true,
|
|
373
|
+
* capDrop: ["ALL"],
|
|
374
|
+
* noNewPrivileges: true,
|
|
375
|
+
* init: true,
|
|
376
|
+
* user: "1000:1000",
|
|
377
|
+
* tmpfs: [{ path: "/tmp", size: "32m", mode: 0o1777 }],
|
|
378
|
+
* });
|
|
379
|
+
* ```
|
|
380
|
+
*
|
|
381
|
+
* **Example:** Customize confinement, process limits, and log rotation
|
|
382
|
+
* ```typescript
|
|
383
|
+
* const api = yield* Docker.Container("api", {
|
|
384
|
+
* image: "ghcr.io/acme/api:latest",
|
|
385
|
+
* seccompProfile: "./security/api-seccomp.json", // client-side JSON; edits roll the container
|
|
386
|
+
* appArmorProfile: "my-loaded-profile", // must already be loaded on the host
|
|
387
|
+
* groupAdd: ["1000"],
|
|
388
|
+
* ulimits: { nofile: { soft: 1024, hard: 4096 } },
|
|
389
|
+
* logDriver: "local",
|
|
390
|
+
* logOptions: { "max-size": "10m", "max-file": "3" },
|
|
391
|
+
* oomScoreAdjustment: 200,
|
|
392
|
+
* stopSignal: "SIGTERM",
|
|
393
|
+
* });
|
|
394
|
+
* ```
|
|
395
|
+
*
|
|
283
396
|
* **Example:** Share a donor container's network namespace
|
|
284
397
|
* ```typescript
|
|
285
398
|
* const donor = yield* Docker.Container("donor", { image: "redis:alpine" });
|
|
@@ -338,6 +451,16 @@ export interface Container extends Resource<
|
|
|
338
451
|
* });
|
|
339
452
|
* ```
|
|
340
453
|
*
|
|
454
|
+
* ### Looking up an existing container
|
|
455
|
+
* `Container.lookup` references a container Alchemy doesn't manage (created with
|
|
456
|
+
* the Docker CLI, Compose or another stack). It is read during plan/deploy,
|
|
457
|
+
* never created, updated or removed.
|
|
458
|
+
*
|
|
459
|
+
* **Example:** Reference an existing container
|
|
460
|
+
* ```typescript
|
|
461
|
+
* const db = Docker.Container.lookup({ name: "postgres" });
|
|
462
|
+
* ```
|
|
463
|
+
*
|
|
341
464
|
* @resource
|
|
342
465
|
* @product Container
|
|
343
466
|
*/
|
|
@@ -381,6 +504,14 @@ export const ContainerProvider = () =>
|
|
|
381
504
|
return yield* sha256(digests.join("\n"));
|
|
382
505
|
});
|
|
383
506
|
|
|
507
|
+
// Docker CLI loads seccomp profiles from client-side files at create
|
|
508
|
+
// time. Hash the contents as well as the path so policy edits are never
|
|
509
|
+
// silently ignored on subsequent deploys.
|
|
510
|
+
const seccompProfileDigest = Effect.fn(function* (profile: string | undefined) {
|
|
511
|
+
if (!isCustomSeccompProfile(profile)) return undefined;
|
|
512
|
+
return yield* fs.readFile(path.resolve(profile)).pipe(Effect.flatMap(sha256));
|
|
513
|
+
});
|
|
514
|
+
|
|
384
515
|
// Without env files the hash input is unchanged from before they were
|
|
385
516
|
// supported, so existing containers keep their label and are not rolled.
|
|
386
517
|
const desiredConfigHash = Effect.fn(function* (
|
|
@@ -388,10 +519,12 @@ export const ContainerProvider = () =>
|
|
|
388
519
|
news: ContainerProps,
|
|
389
520
|
) {
|
|
390
521
|
const envFiles = yield* envFilesDigest(news.envFiles);
|
|
522
|
+
const seccompProfile = yield* seccompProfileDigest(news.seccompProfile);
|
|
391
523
|
return yield* sha256Object({
|
|
392
524
|
...args,
|
|
393
525
|
imageId: normalizeImageId(news.image),
|
|
394
526
|
...(envFiles === undefined ? {} : { envFiles }),
|
|
527
|
+
...(seccompProfile === undefined ? {} : { seccompProfile }),
|
|
395
528
|
});
|
|
396
529
|
});
|
|
397
530
|
|
|
@@ -454,6 +587,11 @@ export const ContainerProvider = () =>
|
|
|
454
587
|
|
|
455
588
|
return Container.Provider.of({
|
|
456
589
|
list: () => Effect.succeed([]),
|
|
590
|
+
lookup: Effect.fn(function* ({ name, context }) {
|
|
591
|
+
const info = yield* inspect(name, context);
|
|
592
|
+
return info ? toContainerAttributes(info, info.Config.Image) : undefined;
|
|
593
|
+
}),
|
|
594
|
+
|
|
457
595
|
read: Effect.fn(function* ({ id, instanceId, olds, output }) {
|
|
458
596
|
const context = dockerContextName(olds.context);
|
|
459
597
|
const name = yield* dockerPhysicalName(id, olds, instanceId);
|
|
@@ -492,9 +630,9 @@ export const ContainerProvider = () =>
|
|
|
492
630
|
) {
|
|
493
631
|
return { action: "update" as const };
|
|
494
632
|
}
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
if (news.envFiles?.length) {
|
|
633
|
+
// File-backed inputs can change without changing their paths.
|
|
634
|
+
// Compare their digests against the live config hash.
|
|
635
|
+
if (news.envFiles?.length || isCustomSeccompProfile(news.seccompProfile)) {
|
|
498
636
|
const live = yield* inspect(newArgs.name, dockerContextName(news.context));
|
|
499
637
|
const applied = live?.Config.Labels?.[CREATE_CONFIG_HASH_LABEL];
|
|
500
638
|
if (applied !== undefined && applied !== (yield* desiredConfigHash(newArgs, news))) {
|
|
@@ -538,7 +676,12 @@ export const ContainerProvider = () =>
|
|
|
538
676
|
!matchesLegacyConfig(live, args, news.image) ||
|
|
539
677
|
// Env file contents can't be compared without a label (e.g. an
|
|
540
678
|
// adopted container); recreate once so later edits are tracked.
|
|
541
|
-
(news.envFiles?.length ?? 0) > 0
|
|
679
|
+
(news.envFiles?.length ?? 0) > 0 ||
|
|
680
|
+
isCustomSeccompProfile(news.seccompProfile) ||
|
|
681
|
+
// A legacy/adopted container has no stamped config hash.
|
|
682
|
+
// Do not retain it without proof that the requested security
|
|
683
|
+
// restrictions were applied.
|
|
684
|
+
hasHardeningOptions(news)
|
|
542
685
|
: live.Config.Labels[CREATE_CONFIG_HASH_LABEL] !== configHash);
|
|
543
686
|
if (recreate) {
|
|
544
687
|
yield* remove(live.Id, context);
|
|
@@ -634,6 +777,30 @@ const makeCreateArgs = (id: string, news: ContainerProps, instanceId: string) =>
|
|
|
634
777
|
"add-host": news.extraHosts,
|
|
635
778
|
network: normalizeNetworkMode(news.networkMode),
|
|
636
779
|
"cap-add": normalizeCapabilities(news.capAdd),
|
|
780
|
+
...(news.capDrop?.length ? { "cap-drop": normalizeCapabilities(news.capDrop) } : {}),
|
|
781
|
+
...(news.memory !== undefined ? { memory: news.memory } : {}),
|
|
782
|
+
...(news.memorySwap !== undefined ? { "memory-swap": news.memorySwap } : {}),
|
|
783
|
+
...(news.memoryReservation !== undefined
|
|
784
|
+
? { "memory-reservation": news.memoryReservation }
|
|
785
|
+
: {}),
|
|
786
|
+
...(news.cpus !== undefined ? { cpus: news.cpus } : {}),
|
|
787
|
+
...(news.pidsLimit !== undefined ? { "pids-limit": news.pidsLimit } : {}),
|
|
788
|
+
...(news.readOnlyRootFilesystem ? { "read-only": true } : {}),
|
|
789
|
+
...(normalizeSecurityOptions(news) ? { "security-opt": normalizeSecurityOptions(news) } : {}),
|
|
790
|
+
...(news.userNamespace !== undefined ? { userns: news.userNamespace } : {}),
|
|
791
|
+
...(news.groupAdd?.length ? { "group-add": normalizeGroups(news.groupAdd) } : {}),
|
|
792
|
+
...(Object.keys(news.ulimits ?? {}).length ? { ulimit: normalizeUlimits(news.ulimits) } : {}),
|
|
793
|
+
...(news.logDriver !== undefined ? { "log-driver": news.logDriver } : {}),
|
|
794
|
+
...(Object.keys(news.logOptions ?? {}).length
|
|
795
|
+
? { "log-opt": normalizeLogOptions(news.logOptions) }
|
|
796
|
+
: {}),
|
|
797
|
+
...(news.oomScoreAdjustment !== undefined
|
|
798
|
+
? { "oom-score-adj": news.oomScoreAdjustment }
|
|
799
|
+
: {}),
|
|
800
|
+
...(news.stopSignal !== undefined ? { "stop-signal": news.stopSignal } : {}),
|
|
801
|
+
...(news.init ? { init: true } : {}),
|
|
802
|
+
...(news.user !== undefined ? { user: news.user } : {}),
|
|
803
|
+
...(news.tmpfs?.length ? { tmpfs: normalizeTmpfs(news.tmpfs) } : {}),
|
|
637
804
|
device: normalizeDevices(news.devices),
|
|
638
805
|
restart: news.restart ?? "no",
|
|
639
806
|
label: news.labels,
|
|
@@ -689,6 +856,64 @@ const normalizeCapabilities = (capAdd: string[] | undefined): string[] | undefin
|
|
|
689
856
|
return [...new Set(capAdd.map((capability) => capability.trim()).filter(Boolean))].sort();
|
|
690
857
|
};
|
|
691
858
|
|
|
859
|
+
const isCustomSeccompProfile = (profile: string | undefined): profile is string =>
|
|
860
|
+
profile !== undefined && profile !== "builtin" && profile !== "unconfined";
|
|
861
|
+
|
|
862
|
+
const normalizeSecurityOptions = (props: ContainerProps): string[] | undefined => {
|
|
863
|
+
const options = [
|
|
864
|
+
...(props.noNewPrivileges ? ["no-new-privileges"] : []),
|
|
865
|
+
...(props.seccompProfile ? [`seccomp=${props.seccompProfile}`] : []),
|
|
866
|
+
...(props.appArmorProfile ? [`apparmor=${props.appArmorProfile}`] : []),
|
|
867
|
+
];
|
|
868
|
+
return options.length ? options : undefined;
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
const normalizeGroups = (groups: string[]): string[] =>
|
|
872
|
+
[...new Set(groups.map((group) => group.trim()))].sort();
|
|
873
|
+
|
|
874
|
+
const normalizeUlimits = (limits: ContainerProps["ulimits"]): string[] =>
|
|
875
|
+
Object.entries(limits ?? {})
|
|
876
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
877
|
+
.map(([name, { soft, hard }]) => `${name}=${soft}:${hard}`);
|
|
878
|
+
|
|
879
|
+
const normalizeLogOptions = (options: ContainerProps["logOptions"]): string[] =>
|
|
880
|
+
Object.entries(options ?? {})
|
|
881
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
882
|
+
.map(([name, value]) => `${name}=${value}`);
|
|
883
|
+
|
|
884
|
+
const normalizeTmpfs = (mounts: Container.TmpfsMount[]): string[] =>
|
|
885
|
+
[...mounts]
|
|
886
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
887
|
+
.map(({ path, size, mode }) => {
|
|
888
|
+
const options = [
|
|
889
|
+
...(size !== undefined ? [`size=${size}`] : []),
|
|
890
|
+
...(mode !== undefined ? [`mode=${mode.toString(8)}`] : []),
|
|
891
|
+
];
|
|
892
|
+
return options.length > 0 ? `${path}:${options.join(",")}` : path;
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
const hasHardeningOptions = (props: ContainerProps): boolean =>
|
|
896
|
+
(props.capDrop?.length ?? 0) > 0 ||
|
|
897
|
+
props.memory !== undefined ||
|
|
898
|
+
props.memorySwap !== undefined ||
|
|
899
|
+
props.memoryReservation !== undefined ||
|
|
900
|
+
props.cpus !== undefined ||
|
|
901
|
+
props.pidsLimit !== undefined ||
|
|
902
|
+
props.readOnlyRootFilesystem === true ||
|
|
903
|
+
props.noNewPrivileges === true ||
|
|
904
|
+
props.seccompProfile !== undefined ||
|
|
905
|
+
props.appArmorProfile !== undefined ||
|
|
906
|
+
props.userNamespace !== undefined ||
|
|
907
|
+
(props.groupAdd?.length ?? 0) > 0 ||
|
|
908
|
+
Object.keys(props.ulimits ?? {}).length > 0 ||
|
|
909
|
+
props.logDriver !== undefined ||
|
|
910
|
+
Object.keys(props.logOptions ?? {}).length > 0 ||
|
|
911
|
+
props.oomScoreAdjustment !== undefined ||
|
|
912
|
+
props.stopSignal !== undefined ||
|
|
913
|
+
props.init === true ||
|
|
914
|
+
props.user !== undefined ||
|
|
915
|
+
(props.tmpfs?.length ?? 0) > 0;
|
|
916
|
+
|
|
692
917
|
const normalizeDevices = (devices: Container.DeviceMapping[] | undefined): string[] | undefined => {
|
|
693
918
|
if (!devices?.length) return undefined;
|
|
694
919
|
const normalized = devices.map(
|
|
@@ -707,6 +932,102 @@ export class InvalidContainerOptions extends Data.TaggedError("InvalidContainerO
|
|
|
707
932
|
}> {}
|
|
708
933
|
|
|
709
934
|
const validateContainerOptions = (news: ContainerProps) => {
|
|
935
|
+
// The CLI formatter omits empty strings. Reject them instead of silently
|
|
936
|
+
// dropping user-specified resource and security restrictions.
|
|
937
|
+
for (const option of [
|
|
938
|
+
"memory",
|
|
939
|
+
"memorySwap",
|
|
940
|
+
"memoryReservation",
|
|
941
|
+
"user",
|
|
942
|
+
"seccompProfile",
|
|
943
|
+
"appArmorProfile",
|
|
944
|
+
"userNamespace",
|
|
945
|
+
"logDriver",
|
|
946
|
+
"stopSignal",
|
|
947
|
+
] as const) {
|
|
948
|
+
if (news[option] !== undefined && news[option].trim().length === 0) {
|
|
949
|
+
return Effect.fail(
|
|
950
|
+
new InvalidContainerOptions({
|
|
951
|
+
message: `Docker.Container ${option} cannot be empty`,
|
|
952
|
+
}),
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
if (news.capDrop?.some((capability) => capability.trim().length === 0)) {
|
|
957
|
+
return Effect.fail(
|
|
958
|
+
new InvalidContainerOptions({
|
|
959
|
+
message: "Docker.Container capDrop cannot contain empty capabilities",
|
|
960
|
+
}),
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
if (news.groupAdd?.some((group) => group.trim().length === 0)) {
|
|
964
|
+
return Effect.fail(
|
|
965
|
+
new InvalidContainerOptions({
|
|
966
|
+
message: "Docker.Container groupAdd cannot contain empty groups",
|
|
967
|
+
}),
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
for (const [name, limit] of Object.entries(news.ulimits ?? {})) {
|
|
971
|
+
if (
|
|
972
|
+
!/^[a-z][a-z0-9_-]*$/.test(name) ||
|
|
973
|
+
!Number.isSafeInteger(limit.soft) ||
|
|
974
|
+
!Number.isSafeInteger(limit.hard) ||
|
|
975
|
+
limit.soft < -1 ||
|
|
976
|
+
limit.hard < -1 ||
|
|
977
|
+
(limit.hard !== -1 && (limit.soft === -1 || limit.soft > limit.hard))
|
|
978
|
+
) {
|
|
979
|
+
return Effect.fail(
|
|
980
|
+
new InvalidContainerOptions({
|
|
981
|
+
message: `Docker.Container ulimits contains an invalid soft/hard limit for ${name}`,
|
|
982
|
+
}),
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
for (const [name, value] of Object.entries(news.logOptions ?? {})) {
|
|
987
|
+
if (!name.trim() || name.includes("=") || !value.trim()) {
|
|
988
|
+
return Effect.fail(
|
|
989
|
+
new InvalidContainerOptions({
|
|
990
|
+
message: `Docker.Container logOptions contains an invalid option: ${name}`,
|
|
991
|
+
}),
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
if (
|
|
996
|
+
news.oomScoreAdjustment !== undefined &&
|
|
997
|
+
(!Number.isInteger(news.oomScoreAdjustment) ||
|
|
998
|
+
news.oomScoreAdjustment < -1000 ||
|
|
999
|
+
news.oomScoreAdjustment > 1000)
|
|
1000
|
+
) {
|
|
1001
|
+
return Effect.fail(
|
|
1002
|
+
new InvalidContainerOptions({
|
|
1003
|
+
message: "Docker.Container oomScoreAdjustment must be an integer from -1000 to 1000",
|
|
1004
|
+
}),
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
if (news.memorySwap !== undefined && news.memory === undefined) {
|
|
1008
|
+
return Effect.fail(
|
|
1009
|
+
new InvalidContainerOptions({
|
|
1010
|
+
message: "Docker.Container memorySwap requires memory to be set",
|
|
1011
|
+
}),
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
if (news.cpus !== undefined && (!Number.isFinite(news.cpus) || news.cpus <= 0)) {
|
|
1015
|
+
return Effect.fail(
|
|
1016
|
+
new InvalidContainerOptions({
|
|
1017
|
+
message: "Docker.Container cpus must be a finite, positive number",
|
|
1018
|
+
}),
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
if (
|
|
1022
|
+
news.pidsLimit !== undefined &&
|
|
1023
|
+
(!Number.isInteger(news.pidsLimit) || (news.pidsLimit <= 0 && news.pidsLimit !== -1))
|
|
1024
|
+
) {
|
|
1025
|
+
return Effect.fail(
|
|
1026
|
+
new InvalidContainerOptions({
|
|
1027
|
+
message: "Docker.Container pidsLimit must be a positive integer or -1 (unlimited)",
|
|
1028
|
+
}),
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
710
1031
|
if (
|
|
711
1032
|
isContainerNetworkMode(news.networkMode) &&
|
|
712
1033
|
((news.ports?.length ?? 0) > 0 || (news.networks?.length ?? 0) > 0)
|
|
@@ -728,6 +1049,41 @@ const validateContainerOptions = (news: ContainerProps) => {
|
|
|
728
1049
|
}
|
|
729
1050
|
targets.add(device.containerPath);
|
|
730
1051
|
}
|
|
1052
|
+
const tmpfsTargets = new Set<string>();
|
|
1053
|
+
for (const mount of news.tmpfs ?? []) {
|
|
1054
|
+
if (mount.size !== undefined && mount.size.trim().length === 0) {
|
|
1055
|
+
return Effect.fail(
|
|
1056
|
+
new InvalidContainerOptions({
|
|
1057
|
+
message: `Docker.Container tmpfs size cannot be empty for ${mount.path}`,
|
|
1058
|
+
}),
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
if (!mount.path.startsWith("/") || mount.path.includes(":") || mount.path.includes(",")) {
|
|
1062
|
+
return Effect.fail(
|
|
1063
|
+
new InvalidContainerOptions({
|
|
1064
|
+
message: `Docker.Container tmpfs target must be an absolute path: ${mount.path}`,
|
|
1065
|
+
}),
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
if (tmpfsTargets.has(mount.path)) {
|
|
1069
|
+
return Effect.fail(
|
|
1070
|
+
new InvalidContainerOptions({
|
|
1071
|
+
message: `Docker.Container tmpfs contains duplicate target ${mount.path}`,
|
|
1072
|
+
}),
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
if (
|
|
1076
|
+
mount.mode !== undefined &&
|
|
1077
|
+
(!Number.isInteger(mount.mode) || mount.mode < 0 || mount.mode > 0o7777)
|
|
1078
|
+
) {
|
|
1079
|
+
return Effect.fail(
|
|
1080
|
+
new InvalidContainerOptions({
|
|
1081
|
+
message: `Docker.Container tmpfs mode is not a valid POSIX mode for ${mount.path}`,
|
|
1082
|
+
}),
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
tmpfsTargets.add(mount.path);
|
|
1086
|
+
}
|
|
731
1087
|
return Effect.void;
|
|
732
1088
|
};
|
|
733
1089
|
|
package/src/Context.ts
CHANGED
|
@@ -41,9 +41,19 @@ export interface Context extends Resource<
|
|
|
41
41
|
docker?: string;
|
|
42
42
|
},
|
|
43
43
|
never,
|
|
44
|
-
Providers
|
|
44
|
+
Providers,
|
|
45
|
+
ContextLookup
|
|
45
46
|
> {}
|
|
46
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Props for {@link Context.lookup}: find a Docker context this
|
|
50
|
+
* stack doesn't manage by its name.
|
|
51
|
+
*/
|
|
52
|
+
export interface ContextLookup {
|
|
53
|
+
/** Context name. */
|
|
54
|
+
name: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
47
57
|
/**
|
|
48
58
|
* A named Docker CLI context — a pointer to a Docker engine (local socket,
|
|
49
59
|
* SSH host, or TCP endpoint) that other Docker resources deploy through.
|
|
@@ -89,6 +99,17 @@ export interface Context extends Resource<
|
|
|
89
99
|
* });
|
|
90
100
|
* ```
|
|
91
101
|
*
|
|
102
|
+
* ### Looking up an existing context
|
|
103
|
+
* `Context.lookup` references a context Alchemy doesn't manage (created with
|
|
104
|
+
* the Docker CLI, Compose or another stack). It is read during plan/deploy,
|
|
105
|
+
* never created, updated or removed.
|
|
106
|
+
*
|
|
107
|
+
* **Example:** Deploy to an existing context
|
|
108
|
+
* ```typescript
|
|
109
|
+
* const remote = Docker.Context.lookup({ name: "prod-host" });
|
|
110
|
+
* const api = yield* Docker.Container("Api", { image: "nginx:alpine", context: remote });
|
|
111
|
+
* ```
|
|
112
|
+
*
|
|
92
113
|
* @resource
|
|
93
114
|
* @product Context
|
|
94
115
|
*/
|
|
@@ -107,6 +128,11 @@ export const ContextProvider = () =>
|
|
|
107
128
|
|
|
108
129
|
return Context.Provider.of({
|
|
109
130
|
list: () => Effect.succeed([]),
|
|
131
|
+
lookup: Effect.fn(function* ({ name }) {
|
|
132
|
+
const live = yield* inspect(name);
|
|
133
|
+
return live ? toContextAttributes(live) : undefined;
|
|
134
|
+
}),
|
|
135
|
+
|
|
110
136
|
read: Effect.fn(function* ({ id, instanceId, olds, output }) {
|
|
111
137
|
const name = yield* dockerPhysicalName(id, olds, instanceId);
|
|
112
138
|
const live = yield* inspect(name);
|